前言
本人再看深入理解Linux内核的时候发现比较难懂,看了Linux系统编程一说后,觉得Linux系统编程还是简单易懂些,并且两本书都是讲Linux比较底层的东西,只不过侧重点不同,本文就以Linux系统编程为例并且会穿插一些深入理解Linux内核的内容来写。
1 入门与基本概念
本书的背景
Linux内核3.9,gcc编译器4.8,C库2.17
文件和文件系统
文件必须打开才能访问
同一个文件可以由多个进程或者同一个进程多次打开。系统会为每个打开的文件实例提供唯一描述符。进程可以共享文件描述符,用户空间的程序一般需要自己协调来确保对文件的同步访问是合理的。
目录和链接
文件通常是通过文件名访问的,通过索引节点很麻烦,目录上存放是索引节点和文件名的对应关系。文件名和inode是通过hlist_head,hlist_node这两个数据结构来实现哈希列表的访问方式。
Linux内核采用缓存(dentry cache)存储目录的解析结果,使用相对路径的时候,内核会获取当前的目录的绝对目录,用相对路径和当前工作目录的组合得到绝对路径。
硬链接
当不同的名字的多个链接映射到一个索引接点上时,这个链接为硬链接。
索引节点删除需要link count为0.
符号链接
有inode和block,block中放的是链接的文件的绝对路径
特殊文件
Linux只支持四种特殊文件:块设备文件,字符设备文件,命名管道,UNIX域套接字。特殊文件遵循一切皆文件的理念。
字符设备是作为线性队列来访问的。漏读数据或者不按顺序读是不可能的。
块设备是作为字节数组来访问。可以随机访问数组中的任何字节。
文件系统和命令空间
块设备的最小寻址单元是扇区sector。一般是512b
文件系统的最小单位是块block,一般sector<block<page(内存的最小寻址单元)
UNIX系统只有一个共享的命名空间,对所有的用户和进程都可见。Linux支持进程间的独立的命名空间,允许每个进程都可以持有系统文件和目录层次的唯一视图。默认情况下,每个进程都继承父进程的命名空间,但是进程也可以选择创建自己的命名空间,包含自己的挂载点和独立的根目录。
进程
进程是执行时的目标代码:活动的,正在运行的程序。但是进程不仅包含目标代码,它还包含数据,资源,状态和虚拟计算机。
最重要的是文本段,数据段,bss段。文本段包含可执行代码和只读数据如常量,通常标记为只读和可执行。数据段包含初始化的数据,包含给定C变量,通常标记为可读写。bss(block started by symbol)段包含未初始化的全局数据。bss段的设计完全是出于性能优化。
线程
线程包含栈,处理器状态,目标代码的当前位置。进程的其他部分由所有线程共享,最主要的是进程地址空间。
线程和轻量级进程都是操作系统相关的概念,Linux内核实现了独特的线程模型,它们其实是共享某些资源的不同进程。
权限
Linux还支持访问控制列表。ACL支持更详细的权限和安全控制方式,代价是复杂度变大和磁盘开销。
信号
信号是一种单向异步的通知机制,由于信号的异步性,处理函数需要注意不要破坏之前的代码,只执行异步安全(async-safe,信号安全)的函数。
2 文件I/O
在对文件进行读写操作之前,首先要打开文件,内核会为每个进程维护一个打开文件的列表,这个列表是由一些非负整数进行索引,这些非负整数称为文件描述符。
文件描述符从0开始到上限值-1,每个进程至少包含3个文件描述符:0,1,2,除非显示的关闭这些描述符。
遵循一切接文件的理念,几乎所有能够读写的东西都可以通过文件描述符来访问。
新建文件的权限
umask是进程级的属性,由login shell设置通过umask来修改。umask为022,取反为755,然后与mode参数0666取与为0644,新建文件的权限为644。
write()行为
用户空间发起write()系统调用时,Linux内核会做几个检查,然后把数据从提供的缓冲区拷贝到内核缓冲区。然后后台内核收集所有的脏页,进行排序优化,然后再写入磁盘(这个过程称为回写)。这就是延迟写。为了保证数据按时写入,内核设置了最大缓存时效(maximum buffer age),通过/proc/sys/vm/dirtytime_expire_seconds ,单位是厘秒(0.01秒)。linux也支持强制文件缓存写回,甚至是将所有的写操作同步。
下面是我Ubuntu虚拟机的设置
root@wis-virtual-machine:~# cat /proc/sys/vm/dirtytime_expire_seconds
43200
同步I/O
Linux内核提供了一些选择可以牺牲性能换来同步操作。
fsync是在硬件驱动器确认数据和元数据都写到磁盘之后返回,对于包含写缓存的磁盘,fsync无法知道数据是否已经真正在屋里磁盘上了,硬盘会报告说数据已经写完了,实际上数据还在磁盘的写缓存上。
fdatasync会写文件的大小,fdatasync不保证非基础的元数据也写到磁盘上,它比fsync更快,它不考虑元数据如文件修改时间戳。
这两个函数不保证文件相关的目录也写到磁盘上,所以也需要对更新的目录使用同步函数。
O_SYNC与O_DSYNC
O_SYNC可以理解为调用write之后再调用fsync。
O_DSYNC可以理解为调用write之后再调用fdatasync。
直接I/O
O_DIRECT位使I/O操作都会忽略页缓存机制,直接对用户空间缓冲区和设备进行初始化。所有的I/O都是同步的。使用直接I/O时,请求长度,缓冲区以及文件偏移都必须是底层设备扇区大小(一般是512字节)的整数倍。
页缓存
时间局部性原理是认为刚被访问的资源在不久之后再次被访问的概率很高。
空间局部性原理是认为数据访问往往是连续的。预读功能就是利用的这个原理。
页回写
一下两种情况会发生页回写:
- 当内存空余小于设定的阈值的时候,脏页就会写到磁盘上,这样来释放内存。
- 脏页的时长超过设定的阈值。
回写是由一组flusher的内核线程来执行的。当上述的条件满足时,这个线程就被唤醒,把脏页写到缓冲区,直到不满足回写的触发条件。
3 缓冲I/O
要读取1024个字节,如果每次只读取一个字节需要执行1024次系统调用,如果一次读取1024个字节那么就只需要一次系统调用。对于前一种提升性能的方法是用户缓冲I/O(user-buffered I/O),读写数据并没有任何变化,而实际上,只有数据量大小达到文件系统块大小张数倍的时候,才会执行真正的I/O操作。
块大小
块大小一般是512,1024,2048,4096个字节,I/O操作最简单的方式是设置一个较大的缓冲区,是标准块的整数倍,比如设置成4096,或者8192。
实际应用程序的读写都是在自己的缓冲区。写的时候数据会被存储到程序地址空间的缓冲区,当缓冲区数据大小达到给定值的时候,整个缓冲区会通过一次写操作全部写出。读也是一次读入用户缓冲区大小且块对齐的数据,应用读取数据是从缓冲区一块一块读的,当缓冲区为空时,会读取另一个块对齐的数据,。通过这种方式,虽然设置的读写大小很不合理,数据会从缓冲区中读取,因此对文件系统还是发送大的块对齐的读写请求。其结果是对于大量数据,系统调用次数更少,且每次读请求的数据大小都是块对齐的。通过这种方式,可以确保有很大的性能提升。
标准I/O
C标准库中提供了标准I/O库,它实现了跨平台的用户缓冲解决方案。
应用是使用标准I/O(可以定制用户缓冲行为),还是直接使用系统调用,这些都是开发人员应该慎重权衡应用的需求和行为后确定。
文件指针
标准I/O程序集不是直接操作文件描述符,他们通过唯一表示符,即文件指针来操作。在C标准库里,文件指针和文件描述符一一映射。文件指针是由指向FILE的指针表示,FILE定义在<stdio.h>中。
在标准I/O中,打开的文件成为流(stream)。流可以用来读(输入流),写(输出流)或者而且都有(输入/输出流)。
获取关联的文件描述符
当不存在和流相关的标准I/O函数时,可以通过文件描述符对该流执行系统调用。为了获得和流相关的文件描述符,可以使用fileno函数:
# include <stdio.h>
int fileno(FILE *stream);
成功时返回关联的文件描述符。最好永远都不要混合使用文件描述符和基于流的I/O操作。
控制缓冲
标准I/O提供了三种类型的用户缓冲:
- 无缓冲(Unbuffered)数据直接提交给内核,很少使用,标准错误默认是采用无缓冲模式。
- 行缓冲(Line-buffered)缓冲以行为单位,没遇到换行符缓冲区就会被提交到内核。行缓冲是默认终端的缓冲模式,比如标准输出。
- 块缓冲(block-buffered)缓冲以块为单位执行,每个块都是固定的字节数。默认情况和文件相关的所有流都是块缓冲模式,标准I/O称块缓冲为完全缓冲(full buffering)。
线程安全
在访问共享数据时,有两种方式可以避免修改它:
- 数据同步访问(synchronized access)机制(加锁)
- 把数据存储在线程的局部变量中
标准I/O函数本质上是线程安全的,每个函数内部都关联了一把锁,一个锁计数器,以及持有该锁并打开一个流的线程。每个线程在执行任何I/O请求之前,都必须持有该锁。因此,在单个函数调用中,标准I/O操作是原子操作。
标准I/O的缺点
标准I/O最大的诟病是两次拷贝带来的性能开销。当读取数据时,标准I/O会向内核发起read系统调用,把数据从内核复制到标准I/O缓冲区,当应用通过标准I/O如fgetc发起读请求的时候,又会拷贝数据,这次是从标准I/O缓冲区拷贝到指定缓冲区。写入请求刚还相反,数据先从指定缓冲区拷贝到标准I/O缓冲区,然后又通过write函数,从标准I/O缓冲区写入内核。
4 高级I/O
分散/聚集I/O
分散聚集I/O是一种可以在单次系统调用中对多个缓冲区输入输出的方法,可以把多个缓冲区的数据写到单个数据流,也可以把单个数据流读到多个缓冲区。这种输入输出方法也称为向量I/O(vector I/O)。之前提到的标准读写系统调用可以称为线性I/O(linear I/O)。
向量I/O是通过readv和writev这个两个系统调用来实现的。
#include <sys/uio.h>
ssize_t readv(int fd,
const struct iovec *iov,
int count); #include <sys/uio.h>
ssize_t writev(int fd,
const struct iovec *iov,
int count);
除了操作多个缓冲区之外,readv和writev功能和read,write功能一致。
每个iovec结构体描述一个独立的,物理不连续的缓冲区,我们称之为段(segment):
#include <sys/uio.h>
struct iovec{
void *iov_base; /* pointer to start of buffer */
size_t iov_len; /* size of buffer in bytes */
}
一组段的集合称为向量(vector)。每个段描述了内存中需要读写的缓冲区地址和长度。readv函数在处理下个缓冲区之前,会填满当前缓冲区的iov-len个字节。writev函数在处理下个缓冲区之前也会把当前缓冲区所有iov_len个字节输出。这两个函数都会顺序处理向量中的段,从iov[0]开始,接着iov[1],一直到iov[count -1]。
Linux内核中所有的I/O都是向量I/O,read和write是作为向量I/O实现的,且向量中只有一个段。
Event Poll
边缘出发(Level-triggered)与条件触发(Edge-triggered)
条件触发是默认行为,poll和select就是这种模式。
存储映射
内核支持应用程序将文件映射到内存中,即内存地址和文件数据一一对应。这样开发人员就可以直接通过内存来访问文件,Linux实现了POSIX.1标准中定义的mmap()系统调用。
#include <sys/mman.h>
void * mmap (void *addr,
size_t len,
int prot,
int flags,
int fd,
off_t offset);
mmap的优点
- 使用read和write系统调用时,需要从用户缓冲区进行读写,而使用映射文件进行操作,避免多余的数据拷贝操作。
- 除了可能潜在的页错误,读写映射文件不会带来系统调用和上下文切换的开销。
- 当多个进程把同一个对象映射到内存中时,数据会在所有进程间共享。只读和写共享的映射在全体中都是共享的,私有可写的映射对尚未进行写时拷贝的页是共享的。
- 在映射对象中搜索只需要简单的指针操作,不需要系统调用lseek。
给出映射提示
Linux提供了系统调用madvise,进程对自己期望如何访问映射区域给内核一些提示信息。
预读
普通文件I/O提示posix_fadvise,readahead
经济适用的操作提示
同步(Synchronized),同步(Synchronous)及异步(Asynchronous)操作
I/O调度器和I/O性能
磁盘寻址
I/O调度器的功能
I/O调度器实现两个基本操作,合并(merging)和排序(sorting)。
合并是讲两个或者多个相邻的I/O请求合并为一个。
排序是选取两个操作中相对重要的一个,幷按块号递增的顺序重新安排等待的I/O请求。如5,20,7这样,排序后就变成5,7,20。如果这个时候有6的访问,这个就会被插入到5和7之间。
改进读请求
如果只是对请求进行排序的话,如一直访问50-60之间的那么100的请求就会一直得不到调度。
处理这个问题的方法就是Linus电梯调度算法,在该方法中如果队列有一定数量的旧的请求,则停止新的请求。这样虽然整体上可以做到平等对待每个请求,但在读的时候却增加了读延迟。这个算法是Linux2.4内核使用的,在2.6废弃了,使用了几种新的调度器算法。
Deadline I/O调度器
这个算法就是在电梯算法上添加了两个队列,分别为读队列和写队列。每个请求到了以后除了加到标准队列,还要加到相应队列的队尾,每个请求都设置了过期时间,读的为200毫秒,写的是5秒。当读写的两个队列的队首超出了过期时间,调度器就会停止从标准队列中处理请求,转而处理相应队列队首的请求。
Anticipatory I/O调度器
这个调度器是deadline的改进版(就是多了一个预测机制),也是三个队列,但是这个调度器会在每个请求到达过期时间之前调度它,不会像deadline那样马上调度它而是等待6毫秒。如果这段时间还有对硬盘同一部分发起请求,这个请求就会立刻响应,Anticipatory 调度器会继续等待。如果6毫秒内没有收到请求,调度器就会认为预测失败,然后返回正常操作(如处理标准队列中断的请求)。
CFQ I/O调度器
CFQ意为Complete Fair Queuing,这个调度器中,每个进程都有自己的队列,每个队列分配一个时间片。调度程序轮询方式处理队列中的请求。知道队列的时间片耗尽或者所有的请求都处理完(如果是所有请求处理完之后会等待一段时间默认10毫秒原因和Anticipatory调度器的预测机制一样)。
CFQ I/O调度器适合大多数场景。
Noop I/O调度器
这个是最简单的调度算法,它只进行合并不做排序。SSD大部分使用这种调度器。
选择和配置你的I/O调度器
有篇文章推荐下:http://orababy.blogspot.com/2014/06/best-io-schedulerelevator-for-oracle.html
要选择调度器可以修改文件/sys/block/device/queue/scheduler来修改。目录/sys/block/device/queue/iosched包含了调度器相关的选项。
我的虚拟机中的设置:
[root@node2 block]# pwd
/sys/block
[root@node2 block]# for i in `ls */queue/scheduler`; do echo $i;cat $i; done
dm-0/queue/scheduler
none
dm-1/queue/scheduler
none
dm-2/queue/scheduler
none
fd0/queue/scheduler
noop [deadline] cfq
sda/queue/scheduler
noop [deadline] cfq
sr0/queue/scheduler
noop deadline [cfq]
[root@node2 iosched]# pwd
/sys/block/sda/queue/iosched
[root@node2 iosched]# for i in `ls`; do echo $i;cat $i; done
fifo_batch
16
front_merges
1
read_expire
500
write_expire
5000
writes_starved
2
优化I/O性能
5 进程管理
程序,进程,线程
运行新进程
等待子进程终止
僵尸进程
守护进程
6 高级进程管理
进程调度
I/O约束型进程和处理器约束型进程
完全公平调度器
I/O优先级
处理器亲和力(Affinity)
实时系统
资源限制
7 线程
二进制程序,进程,线程
线程模型
线程模式
同步与锁
Pthread
线程ID
join加入进程和detach分离进程
8 文件和目录管理
stat函数
目录
新建目录
链接
特殊设备节点
随机数生成器
带外通信(Out-of-Band Communication)
inotify监视文件事件
9 内存管理
进程地址空间
对齐问题
匿名内存映射
内存分配方式(各种方式的优缺点)
锁住内存
投机性内存分配策略
10 信号
SIGHUP
信号管理
可重入
信号的用途和缺点
10 时间
以下是深入理解linux内核中的
IO体系结构与设备驱动程序
sysfs文件系统
/sys 和/proc会继续共存
sysfs文件系统的目的是要展现设备驱动程序模型组件间的关系,该文件系统的相应高层目录
- block 块设备,他们独立于所连接的总线
- devices 所有被内核识别的硬件设备,依照他们的总线对其进行组织
- bus 系统中用于连接设备的总线
- drivers 在内核中注册的设备驱动程序
- class 系统中的设备的类型,声卡,网卡,显卡等等,同一类可能包含不同总线连接的设备,于是由不同的驱动- 程序驱动
- power 处理一些设备电源状态的文件
- firmware 处理一些设备文件固件的文件
直接内存访问DMA
在最初的PC体系结构中,cpu是系统唯一的总线控制器,也就是说为了提取和存储ram存储单元的值,cpu是唯一可以驱动地址/数据总线的硬件设备。
同步DMA和异步DMA
设备驱动程序可以采用两种方式使用DMA,同步DMA是由进程触发数据的传送(声卡播放音乐),异步DMA是由硬件设备触发数据的传送(网卡收到lan的帧,保存到自己的IO共享存储器中,然后引发一个中断,其驱动程序确认该中断后,命令网卡将收到的帧从IO共享存储器拷贝到内核缓冲区,当数据传送完成后,网卡会引发新的中断,然后驱动程序将这个新帧通知给上层内核层)。