注:本文的内容摘录自《Linux/UNIX系统编程手册》(原名The Linux Programming Interface)
系统调用
系统调用是受控的内核入口,借助于这一机制,进程可以请求内核去执行某些动作。内核以应用程序编程接口(API)的形式,提供了一系列的服务供程序访问,包括创建新进程,执行I/O等。
执行系统调用时会完成如下这些步骤:
- 应用程序调用C语言函数库的外壳(wrapper)函数,来发起系统调用
- 外壳函数将系统调用参数放入到特定的寄存器中
- 外壳函数将系统调用编号复制到寄存器%eax
- 外壳函数执行中断机器指令(int 0x80),使处理器从用户态切换到内核态
- 内核调用
system_call()
例程:- 在内核栈中保存寄存器的值
- 审核系统调用编号的有效性
- 以系统调用编号对存放所有调用服务例程的列表进行索引,发现并调用相应的系统调用服务例程,然后将结果状态返回给
system_call()
- 从内核栈恢复各个寄存器的值,并将系统调用返回值置于栈中
- 返回至外壳函数,将处理器切换至用户态
- 如果系统调用的返回值表明调用有误,外壳函数会使用该值来设置全局变量
errno
,然后外壳函数返回调用程序,并同时返回一个整型值,表明系统调用是否成功。
系统限制和选项
概述
UNIX系统中需要对各种各样的系统特性和资源进行限制,并选择提供或者不提供由各种标准定义的选项,例如一个进程能同时拥有多少已打开的文件、路径名的最大长度、一个程序的参数列表可以多大等。在不同的操作系统实现中,这些变量往往不同。
系统限制
针对于系统中的每个限制,所有的实现都必须支持一个最小值,它们被定义为<limits.h>
文件中的常量,命名形如_POSIX_XXX_MAX
。这类常量的每一个都对应着对某类资源或者特性的上限,且要求这些上限具有一个确定的最小值。而在某些情况下,需要为某个限制提供一个最大值,对这些值的命名中包含字符串_MIN
,它们代表了对某些资源的下限。
这些系统限制分为三类:
运行时恒定值:这些值可能依赖于具体的运行环境,需要在程序运行时调用
sysconf()
函数来获取。这一函数用法如下:1
2
long sysconf(int name) //返回name指代的系统限制的数值,如果返回-1则代表这个系统限制未被确定或者发生错误路径名变量值:与路径名(文件、目录、终端等)相关的限制,每个限制可能是相对于某个系统实现的常量,也可能随文件系统的不同而不同。在限制可能因路径名而发生变化的情况下,应用程序可以使用
pathconf()
或fpathconf()
来获取该值。它们的用法如下:1
2
3
long pathconf(const char* pathname, int name) //返回name指代的系统限制的数值,如果返回-1则代表这个系统限制未被确定或者发生错误
long fpathconf(int fd, int name) //返回name指代的系统限制的数值,如果返回-1则代表这个系统限制未被确定或者发生错误运行时可增加值:对于某些系统限制,特定系统在运行时可能会增加该值,应用程序可以使用
sysconf()
来获得系统所支持的实际值。
系统选项
对于一些选项,如实时信号、POSIX共享内存、任务控制等,它们允许自行定义。通过在<unistd.h>
文件中定义相应的常量,便可实现在编译时通过其对特定选项的支持。
各个选项常量在定义之后,其值必为下列之一:
- 值为-1:表示不支持该选项。此时,系统无需定义与该选项相关的头文件、数据类型和函数接口
- 值为0:表示实现可能支持该选项,应用程序需要在运行时检查该选项是否获得支持
- 值大于0:表示实现支持该选项,实现定义了与该选项有关的所有头文件、数据类型和函数接口,其行为也符合规范要求
文件操作
文件系统
设备文件
设备专用文件与系统中的某个设备对应。在内核中,每种设备类型都有与之对应的设备驱动程序,用来处理设备的所有I/O请求。设备驱动程序属内核代码单元,可执行一系列操作,(通常)与相关硬件的输入/输出动作相对应。
由设备驱动程序提供的API 是固定的,包含的操作对应于系统调用open()
、close()
、read()
、write()
、mmap()
以及ioctl()
。每个设备驱动程序所提供的接口一致,这隐藏了每个设备在操作方面的差异,从而满足了I/O 操作的通用性。
一些设备是实际存在的,如鼠标、磁盘和磁带设备;而另一些设备是虚拟的,就是不存在相应的硬件,但是内核会通过设备驱动程序提高一种抽象设备。设备可分为字符型设备和块设备,字符型设备基于每个字符来处理数据,如终端和键盘;而块设备则每次处理一块数据,如磁盘和磁带。在文件系统中,设备文件通常位于/dev
目录下。
每个设备文件都有一个主ID和辅ID,主ID号标识一般的设备登记,内核使用主ID号查找与该类设备相应的驱动程序;而辅ID号则能够在一般等级中唯一标识特定设备。每个设备驱动程序都会将自己与特定主设备号的关联关系向内核注册,从而建立设备专用文件和设备驱动程序之间的关系。
磁盘和分区
常规文件和目录通常都存放在磁盘设备中,每块磁盘都可以划分为一个或者多个不重叠的分区,而内核将每个分区视为/dev
路径下的单独设备。磁盘分区通常只存放三种类型的信息:文件系统、数据区域、交换区域。
文件系统结构
文件系统是对常规文件和目录的组织集合,磁盘的每个分区都对应于一个文件系统。Linux支持多种不同的文件系统,包括传统的文件系统、原生UNIX文件系统、网络文件系统、日志文件系统(文件更新时间变长,但是无需在系统崩溃之后检查文件系统的一致性)等。
在文件系统中,用来分配空间的基本单位是逻辑块,即文件系统所在磁盘设备上若干连续的物理块。文件系统由以下几个部分组成:
- 引导块:文件系统的首块。引导块只是用来引导操作系统的信息。
- 超级块:紧随引导块之后的一个独立块,包含与文件系统有关的参数信息,包括i节点表的容量、文件系统逻辑块的大小、文件系统大小
- i节点表:文件系统中的每个文件或者目录在i节点表中都对应着唯一一条记录,其中包含了关于文件的各种信息,包括文件类型、文件归属、文件访问权限、时间戳、文件大小、分配给文件的块数量、指向文件数据块的指针等。
- 数据块:用于存放数据
在ext2文件系统中,由于存储文件的数据块不一定连续甚至不一定按顺序存放,因此i节点表内维护了一组指针,如下图所示:
其中,每个i节点包含15个指针,前12个指向文件前12个块的位置,而后面则是一个指向指针块的指针,提供了第13个及后续数据块的位置。指针块中指针的数量取决于文件系统中块的大小。而如果文件较大时,也可以是多重的间接指针。这样就在维持i节点结构大小固定的情况下,支持任意大小的文件;同时文件系统可以用不连续方式存储文件块,并支持随机访问文件;此外,对于小文件来说,这种设计也满足了对文件数据块的快速访问(前12个为直接指针)
虚拟文件系统
由于Linux支持的各种文件系统所对应的底层实现细节并不相同,因此虚拟文件系统(VFS)为文件系统创建了一个抽象层来解决这一问题。VFS为文件系统定义了一套通用接口,所有与文件交互的程序都会安装这一接口进行操作,而每种文件系统也会提供VFS接口的实现。
VFS接口的操作与设计文件系统和目录的所有常规系统调用对应,包括open()
, read()
, write()
, lseek()
, close()
, truncate()
, stat()
, mount()
, umount()
, mmap()
, mkdir()
, link()
, unlink()
, symlink()
, rename()
。
目录层级与文件挂载
Linux系统下所有文件系统中的文件都位于根目录树下,而树根就是根目录“/”,其他的文件系统都被挂载在根目录之下,被视为整个目录层级的子树。
在Linux系统下,使用mount device directory
命令即可将名为device的文件系统挂载到目录层级中由directory指定的目录。使用unmount
命令则会相应地卸载文件系统。如果不带任何参数执行mount
命令,则可以列出当前已经挂载地文件系统。
同时,系统调用mount()
也可以用来挂载文件,相应的卸载文件的系统调用为umount()
和umount2()
,它们的用法为:
1 |
|
要获得已挂载文件系统的相关信息,可以使用下面两个系统调用:
1 |
|
上述调用同时会得到一个statvfs
结构,属于由statvfsbuf
指向的缓冲区。其中,statvfs
数据结构包含了关于文件系统的信息。
Linux系统下的三个文件包含了当前已挂载或者可挂载的文件系统信息:
/proc/mounts
:它是内核数据接口的接口,因此总是包含已挂载文件系统的精确信息/etc/mtab
:包含的内容与/proc/mounts
类似,但是更加详细一些。/etc/fstab
:由系统管理员手动维护,包含了对系统支持的所有文件系统的描述
这三个文件的格式相同,都包含6个字段:已挂载设备名、设备挂载点、文件系统类型、挂载标志、用于控制对文件系统备份操作的数字、用于控制对文件系统检查顺序的数字。
虚拟内存文件系统
Linux同样支持驻留在内存中的虚拟文件系统tmpfs。对于应用程序来说,可以和使用其他文件系统一样的方法进行操作,但是由于不涉及磁盘访问,因此虚拟文件系统的操作速度极快。
要创建一个tmpfs文件系统,可以使用如下命令:mount -t tmpfs source target
目录与链接
硬链接
在文件系统中,目录的存储方式与普通文件类似,但是它们有两点不同:
- 在i-node条目中,目录会被标记为一种不同的文件类型
- 目录是经过特殊组织而成的文件,本质上是一个表格,其中包含了文件名和i-node编号
文件i-node中所存储的信息列表中并未包含文件名,仅通过目录列表内的一个映射来定义文件名称。通过这种方式,能够在相同或者不同目录中创建多个名称,每个均指向相同的i-node节点。这些名称被称为链接,有时也被称为硬链接。
在shell中,可以使用ln
命令为一个已存在的文件创建新的硬链接,这样也就相当于同一个文件可以拥有多个名字。此时,如果移除其中一个文件名,另一个文件名以及文件本身将继续存在,但是会将文件i-node的链接计数减1。只有当文件的所有名字都被删除之后(即链接计数变为0),才会释放文件的i-node记录和数据块。
对硬链接有如下限制:
- 硬链接需要与其所指代的文件驻留在同一个文件系统中
- 不能为目录创建硬链接,否则将会导致出现链接环路
软链接
而软链接(符号链接)是一种特殊的文件类型,它的数据是另一文件的名称。在shell中,通过使用ln -s
即可创建一个符号链接,ls -F
命令的输出结果会在符号链接的尾部标记@。符号链接的内容可以是绝对或者相对路径,解释相对路径时将以链接本身的位置作为参照点。
需要注意的是,文件的链接计数中并未计算符号链接。因此,如果移除了符号链接所指向的文件名,符号链接还会继续存在。此时这一链接就变成了悬空链接,无法再对其进行解引用操作。也因此可以为并不存在的文件名创建一个符号链接。
由于符号链接指代一个文件名,因此它可以链接不同文件系统内的文件,也可以为目录创建符号链接。
符号链接直接可能会形成链路,在某些系统调用中如果指定了符号链接,内核会对一系列链接去层层解引用,直到最终文件。需要注意的是,有些系统调用会对符号链接进行解引用,而有些系统调用则对符号链接不做任何处理,直接作用于链接文件本身。
链接的系统调用
创建和移除链接
系统调用link
和unlink
可以被用于创建和移除硬链接,用法如下:
1 |
|
在link
系统调用中,如果oldpath
提供的是一个硬链接,那么将以newpath
参数指定的路径名创建一个新的链接,如果newpath
指定的路径名存在,则产生错误。需要注意的是,link
不会对符号链接进行解引用操作。
而unlink
系统调用则移除一个链接,且如果此链接是指向文件的最后一个链接,则还会移除文件本身。unlink
不能用于移除目录,也不会对符号链接进行解引用操作。
Linux内核除了为每个i-node维护链接计数,还会为文件已打开的文件描述符计数。因此,当移除指向文件的最后一个链接时,如果仍有进程持有指代该文件的打开文件描述符,则在关闭所有的这类描述符之前,系统实际上不会删除该文件。这将允许在取消对文件链接的时候,无需担心是否有其它进程已经将其打开。
更改文件名
rename
系统调用可以用于重命名文件,或者是将文件移动到同一文件系统中的另一目录:
1 |
|
这一调用将现有路径名oldpath
重命名为newpath
参数指定的路径名。该操作仅操作目录条目,而不移动文件数据。改名既不影响指向该文件的其它硬链接,也不影响持有该文件打开描述符的任何进程。
rename
满足下面的规则:
- 如果
newpath
已经存在,则将其覆盖 - 如果
newpath
与oldpath
指向同一文件,则不发生变化 - 两个参数中的符号链接都不解引用
- 如果
oldpath
指代文件,则不能将newpath
指定为一个目录的路径名;而如果oldpath
为目录名,则需要保证newpath
不存在或者是空目录的名称,且newpath
不能包含oldpath
作为其目录前缀,此时相当于对目录重命名 - 两个参数所指代的文件需要位于同一文件系统
使用符号链接
创建符号链接的系统调用如下:
1 |
|
symlink
系统调用会针对于filepath
指定的路径名创建一个新的符号链接linkpath
。如果linkpath
给定的路径名已经存在,则调用失败;而filepath
可以为绝对或者相对路径,且它所命名的文件或者目录在调用时无需存在。
而获取符号链接本身的内容可以用如下系统调用:
1 |
|
这一系统调用会对pathname
进行解引用,将其所指向的路径名称放入buffer
指向的字符数组中,而bufsiz
则对应于buffer
参数的可用字节数。
文件/目录的创建和移除
mkdir
系统调用用于创建一个新的目录:
1 |
|
pathname
参数指定了新目录的路径名称,可为绝对路径或者相对路径;而mode
参数指定了新目录的权限。在新建目录中包含两个条目.
和..
,分别代表指向目录自身的链接和指向父目录的链接。
需要注意的是,这一系统调用所创建的仅仅是路径名中的最后一部分,pathname
参数中的父目录必须存在,这一函数才能执行成功。
如果要删除目录则可以使用如下的系统调用:
1 |
|
pathname
可以为绝对路径也可以为相对路径。要使得rmdir
调用成功,则必须保证pathname
对应的目录为空。如果pathname
为符号链接,则不会对其做解引用操作,并返回错误。
要移除文件或者空目录也可以用remove
库函数:
1 |
|
如果pathname
是一个文件,那么remove
会调用unlink
;而如果pathname
是目录,则调用的是rmdir
。remove
不对符号链接进行解引用操作,如果它是符号链接,则remove
会移除链接本身,而不是链接指向的文件。
读目录
下面两个函数用于打开一个目录,并返回指向该目录的句柄,供后续调用使用。
1 |
|
函数的返回结果是一个DIR
类型的指针,这一结构即为目录流。函数在返回时会将目录流指向目录列表的首条记录。在调用fdopendir
之后,文件描述符将处于系统的控制之下,除了使用个别函数,程序不应该采取任何方式对其进行访问。
对于得到的目录流,可以使用readdir
函数从中读取条目:
1 |
|
每调用readdir
一次,就会从dirp
所指代的目录流中读取下一个目录条目,并返回一个指向静态分配的dirent
数据结构的指针,其中包含了目录条目的信息。每次调用都会覆盖dirent
结构。
readdir
函数的一个变体是readdir_r
,这一函数是可重入的,用法为:
1 |
|
这一函数会将下一项目录条目放在entry
指向的dirent
结构中,同时会在result
放置指向该结构的指针。
rewinddir()
函数可以用于将目录流移动到起点:
1 |
|
而closedir()
函数可以将打开状态的目录流关闭:
1 |
|
一个目录流会与一个文件描述符相关联,dirfd()
函数返回与dirp
目录流相关联的文件描述符:
1 |
|
而如果要递归遍历整个目录子树,可以使用nftw()
函数:
1 |
|
默认情况下,nftw
会针对于给定的树执行未排序的前序遍历,即对于各个目录的处理要比各目录下的文件和子目录优先。其中dirpath
代表要遍历的目录树,func
代表对目录树的每个文件所调用的函数,nopenfd
代表可使用文件描述符数量的最大值,flags
参数可以对函数的操作进行修正。
进程当前工作目录
一个进程的当前工作目录定义了该进程解析相对路径名的起点。新进程的当前工作目录继承自父进程。
要获取当前工作目录可以用getcwd
命令:
1 |
|
这一函数会将内含当前目录绝对路径的字符串放置在cwdbuf
指向的已分配缓冲区中,调用者需要为cwdbuf
缓冲区分配至少size
个字节的空间。一旦调用成功,getcwd
将会返回一枚指向cwdbuf
的指针。如果当前工作目录的路径名长度超过size
,则会返回NULL,并将errno
设置为ERANGE
。
如果cwdbuf
为NULL且size
为0,那么glibc封装函数会为getcwd
按需分配一个缓冲区,并将指向该缓冲区的指针作为函数的返回值。
要改变当前工作目录有两种方法:
1 |
|
chdir
系统调用将调用进程的当前工作目录改为由pathname
指定的相对或者绝对路径名称,如果是符号链接还会解引用;而fchdir
则是在指定目录时使用文件描述符,这一描述符为使用open
打开相应目录时获得的。
改变进程的根目录
每个进程都有一个根目录,该目录是解释绝对路径(即以/开始的目录)时的起点。默认情况下,根目录为文件系统的真实根目录。有些场合需要改变一个进程的根目录,而特权级进程可以通过chroot
系统调用来修改:
1 |
|
chroot
会将进程根目录改为pathname
指定的目录,如果它为符号链接则要对其解引用。
路径解析
realpath
库函数可以用来解除路径中的符号链接,并解析其中对/.
和/..
的引用,从而生成一个以空字符结尾的字符串,内含相应的绝对路径名:
1 |
|
glibc的realpath
实现允许将resolved_path
设置为空,此时函数会为解析生成的路径名称分配一个缓冲区,并将指向该缓冲区的指针作为结果返回。
而dirname
和basename
两个函数可以将一个路径名字符串分解成目录和文件名两部分:
1 |
|
二者返回的字符串拼接起来,即可得到一个完整的路径名。
监控文件事件
概述
某些应用程序需要对文件或目录进行监控,已侦测其是否发生了特定事件。例如,当把文件加入或移出一目录时,图形化文件管理器应能判定此目录是否在其当前显示之列,而守护进程可能也想要监控自己的配置文件,以了解其是否被修改。Linux提供了inotify机制,以允许应用程序监控文件事件。使用inotify API由如下几个关键步骤:
- 使用
inotify_init()
创建一个inotify实例,这一调用会返回一个文件描述符,用于在后续操作中指向该实例 - 应用程序使用
inotify_add_watch()
向inotify实例的监控列表添加条目,告知内核哪些文件是自己的兴趣所在。每个监控项包含一个路径名,以及一个相关的位掩码,指明所要监控的事件集合。 - 为了获得事件通知,应用程序需要针对inotify文件描述符执行
read()
操作,每次对read()
的成功调用,都会返回一个或者多个inotify_event
结构,其中各自记录了处于inotify实例监控之下的某个路径名所发生的事件 - 在结束监控时,应用程序关闭inotify文件描述符,这样便会自动清除与inotify实例相关的所有监控项。
inotify机制不仅可以用于文件,还可以用于目录,监控目录时,与路径自身及其所含文件相关的事件都会通知给应用程序。
API
与inotify相关的API调用包括:
1 |
|
在使用inotify_add_watch
时,位掩码参数mask
标识了针对给定路径名而要监控的事件。
将监控项在监控列表中登记之后,应用程序可以使用read
从inotify文件描述符中读取事件,以判定发生了哪些事件。如果直到读取时尚未发生任何事件,read
调用会阻塞下去,直到有事件产生(如果设置了非阻塞的文件描述符标志,则会报错)
事件发生后,每次调用read
都会返回一个缓冲区,其中包含一个或者多个inotify_event
的数据结构。这一数据结构的定义如下:
1 | struct inotify_event{ |
文件加锁
flock
函数
flock
系统调用在整个文件上放置一个锁。待加锁的文件是一个通过传入fd
的一个打开着的文件描述符:
1 |
|
operation
参数为LOCK_SH(给文件加共享锁)、LOCK_EX(给文件加互斥锁)和LOCK_UN(解锁fd
引用的文件)。如果要进行非阻塞操作,则可以使用或操作加上LOCK_NB选项。
任意数量的进程可同时持有一个文件上的共享锁,但在同一个时刻只有一个进程能够持有一个文件上的互斥锁。
fcntl
函数
使用fcntl()
能够在一个文件的任意部分上放置一把锁,这个文件部分既可以是一个字节,也可以是整个文件。一般来讲,fcntl()
会被用来锁住文件中与应用程序定义的记录边界对应的字节范围,这也是术语记录加锁的由来。Linux系统可以将一个记录锁应用在任意类型的文件描述符上。
用来创建或者删除一个文件锁的fcntl
调用的形式如下:
1 | struct flock flockstr; |
其中,cmd
参数可以为F_SETLK(获取或者释放flockstr
指定的字节上的锁,如果另一个进程持有与待加锁区域任意部分不兼容的锁则返回EAGAIN错误)、F_SETLKW(获取或释放锁,但是如果遇到待加锁区域不兼容的情况则会阻塞等待)、F_GETLK(检测释放可以在给定区域上锁)这三个值的其中一个。
文件I/O
概述
在Linux系统中一切皆可以看成是文件,文件又可分为:普通文件、目录文件、字符设备文件、块设备文件、套接字文件、管道文件和链接文件。文件描述符(file descriptor)是内核为了高效管理已被打开的文件所创建的索引,其是一个非负整数(通常是小整数),用于指代被打开的文件,所有执行I/O操作的系统调用都通过文件描述符。一个程序刚刚启动的时候,0是标准输入,1是标准输出,2是标准错误。如果此时去打开一个新的文件,它的文件描述符会是3。
在Linux系统中,为了维护文件描述符,建立了三个表:进程级的文件描述符表(每个进程维护一个)、系统级的文件描述符表(所有进程共享)和文件系统的i-node表(所有进程共享),它们的关系如下图所示:
每一个文件描述符会与一个打开文件相对应。同时,不同的文件描述符也可以指向同一个文件。相同的文件可以被不同的进程打开,也可以在同一个进程中被多次打开。这些文件描述符也可以被重定向,从而指向其它任何文件对象。
大多数程序使用的3种标准的文件描述符如下:
文件描述符 | 用途 | POSIX名称 | stdio流 |
---|---|---|---|
0 | 标准输入(默认指向键盘) | STDIN_FILENO | stdin |
1 | 标准输出(默认指向终端) | STDOUT_FILENO | stdout |
2 | 标准错误 | STDERR_FILENO | stderr |
执行文件I/O操作的4个主要系统调用为:
fd=open(pathname, flags, mode)
:打开pathname
所标识的文件,并返回文件描述符,用于在后续的函数调用中指代打开的文件。如果文件不存在可以创建,这取决于flags
参数的设置,同时flags
参数还可以指定文件打开方式(只读、只写、读写)。mode
参数指定了由open()
调用创建文件的访问权限numread=read(fd, buffer, count)
:调用从fd
所指代的打开文件中读取至多count
字节的数据,并存储到buffer
中。numwritten=write(fd, buffer, count)
:调用从buffer
中读取多达count
字节的数据写入fd
所指代的已打开文件中。status=close(fd)
:在所有输入/输出操作完成之后,调用该函数释放文件描述符以及与之相关的内核资源
上述四个系统调用可以对所有类型的文件执行I/O操作,包括终端之类的设备。
通用I/O
open()
open()
系统调用既可以打开一个已经存在的文件,也可以创建并打开一个新文件。它的用法如下:
1 |
|
如果打开成功,则返回一个文件描述符,用于在后续的函数调用中指代该文件;如果发生错误,则返回-1,并将errno
设置为相应的错误标志。而且这一函数调用会保证,如果调用成功,则返回值是进程未使用的文件描述符中数值最小者。
其中参数的含义如下:
pathname
:要打开的文件flags
:位掩码,用于指定文件的访问模式,可以用一系列的常量进行位或运算进行组合,例如O_RDWR|O_CREAT|O_TRUNC
mode
:如果使用open()
创建新文件,则这一参数可以用于指定文件的访问权限;如果未指定O_CREAT
标志,则可以省略该参数
对于flags
位掩码,它们可以使用的常量分为如下几组:
- 文件访问模式标志:
O_RDONLY
,O_WRONLY
,O_RDWR
,分别表示以只读、只写、读写模式打开。这三个标志在flags
参数中只能使用一个 - 文件创建标志:
O_CLOEXEC
:为新的文件描述符启用close-on-exec
标志O_CREAT
:如果文件不存在则创建一个新的空文件,此时需要额外提供mode
参数O_DIRECT
:无系统缓冲的文件I/O 操作,也就是应用程序在执行磁盘I/O是绕过缓冲区高速缓存,从用户空间直接将数据传递到文件或者磁盘设备。对于大多数应用而言,使用这一方式可能会大大降低性能,因此它只适用于有特定I/O需求的应用。如果使用直接I/O,则用于传递数据的缓冲区的内存边界、文件和设备的偏移量、以及待传递数据的长度都必须为块大小的整数倍,否则将会导致EINVAL错误O_DIRECTORY
:如果pathname
参数不是目录则返回错误O_EXCL
:此标志与O_CREAT
标志结合使用表明如果文件已经存在,则不会打开文件,且open()
调用失败,并返回错误,错误号errno
为EEXIST
。也就是说,此标志确保了调用者(open( )
的调用进程)就是创建文件的进程。对文件是否存在的检查和创建文件属于同一原子操作。O_LARGEFILE
:支持以大文件方式打开文件。由于存放文件偏移量的数据类型off_t
是一个有符号的长整型数,因此在32位系统下,文件大小的限制为2GB以下。如果在32位系统下要处理大文件,则需要使用这一标志。此外,也可以在每个头文件中加入#define _FILE_OFFSET_BITS 64
来实现。O_NOATIME
:在读文件时,不更新文件的最近访问时间。要使用该标志,要么调用进程的有效用户ID必须与文件的拥有者相匹配,要么进程需要拥有特权(CAP_FOWNER
)。否则,open()
调用失败,并返回错误,错误号errno
为EPERM
。O_NOCTTY
:如果正在打开的文件属于终端设备,这一标志防止其成为控制终端O_NOFOLLOW
:在open()
函数中指定了O_NOFOLLOW
标志,且pathname
参数属于符号链接,则open()
函数将返回失败(错误号errno
为ELOOP
)。此标志在特权程序中极为有用,能够确保open()
函数不对符号链接进行解引用O_TRUNC
:如果文件已经存在且为普通文件,那么将清空文件内容,将其长度置0。在Linux 下使用此标志,无论以读、写方式打开文件,都可清空文件内容(在这两种情况下,都必须拥有对文件的写权限)
- 文件状态标志:
O_APPEND
:总是在文件尾部追加数据。如果使用这一标志,则每次写入都会将文件偏移量移动至文件末尾,并完成数据写操作。这两步操作被合并到同一原子操作,避免了文件的脏写入问题。O_ASYNC
:当对于open()
调用所返回的文件描述符可以实施 I/O 操作时,系统会产生一个信号通知进程O_DSYNC
:根据同步I/O 数据完整性的完成要求来执行文件写操作,也就是说后续对于这个文件的每个write
调用都会自动将文件数据和用于获取数据的文件元数据(并不是所有的元数据)刷新到磁盘上。O_NONBLOCK
:以非阻塞方式打开。如果open()
调用没有立即打开文件,则返回错误,而不是陷入阻塞。而且调用open()
成功之后,后续的I/O操作也是非阻塞的,如果I/O系统调用没有立即完成,则可能只会传输部分数据,或者系统调用失败。O_SYNC
:以同步方式写入文件,也就是说后续对于这个文件的每个write
调用都会自动将所有的文件数据和元数据刷新到磁盘上。
read()
read()
系统调用从文件描述符所指代的打开文件中读取数据,它的用法如下:
1 |
|
其中count
参数指定最多能够读取的字节数,buffer
参数提供用来存放输入数据的内存缓冲区地址,缓冲区应该至少有count
个字节。
如果read()
调用成功,则返回实际读取的字节数;如果遇到文件结束则返回0;如果出现错误则返回-1。
write()
write()
系统调用将数据写入一个已经打开的文件中,它的用法如下:
1 |
|
fd
为待写入文件的描述符,count
参数指定最多能够写入文件的字节数,buffer
参数为要写入文件中数据的内存缓冲区地址,缓冲区应该至少有count
个字节。
如果函数调用成功,将返回实际写入文件的字节数。该返回值可能小于count
,对于磁盘文件可能因为磁盘已满,或者进程资源对于文件大小的限制。
需要注意的是,对磁盘文件执行I/O操作时,write()
调用成功并不能保证数据已经写入磁盘。这是因为为了减少磁盘活动量和加快write()
系统调用,内核会缓存磁盘的I/O操作。
close()
close()
系统调用关闭一个打开的文件描述符,并将其释放回调用进程。这一文件描述符可以供该进程后续分配给其它文件。当一个进程终止时,将自动关闭它打开的所有文件描述符。它的用法为:
1 |
|
如果关闭成功,则返回0;失败则返回-1。
由于文件描述符属于有限资源,因此在程序中显式关闭不再需要的文件描述符是一个好的编程习惯,使得代码更具可读性也更加可靠。
lseek()
对于每个打开的文件,系统内核会记录一个文件偏移量(又被称为读写偏移量或指针)。文件偏移量指的是下一个read()
或者write()
操作的文件起始位置,以相对于文件头部起始点的位置来表示。文件第一个字节的偏移量为0。
打开一个文件时,会将文件偏移量设置为指向文件开始,以后每次read()
或者write()
调用都将自动对其进行调整,以指向已读或者已写数据的下一字节。
它的使用方法如下:
1 |
|
如果设置成功,则返回新的偏移位置,失败则返回-1。其中,fd
为文件描述符,指向一个已打开文件;offset
参数指定了一个以字节为单位的数值,它是一个有符号的整型数;whence
参数表明参照哪个基点来解释offset
参数。
whence
参数可以为下列之一:
SEEK_SET
:将文件偏移量设置为从文件头部起始点开始的offset
个字节。此时offset
必须为非负数。SEEK_CUR
:相对于当前的文件偏移量,将文件偏移量调整offset
个字节。此时offset
可以为正也可以为负。SEEK_END
:将文件偏移量设置为起始于文件尾部的offset
个字节,也就是从文件最后一个字节之后的下一个字节算起。此时offset
可以为正也可以为负。
如果程序的文件偏移量已跨越了文件结尾,然后再执行I/O操作,则会造成文件空洞。从文件结尾后到新写入数据间的这段空间被称为文件空洞。从编程角度看,文件空洞中是存在字节的,读取空洞将返回以0(空字节)填充的缓冲区。然而,文件空洞不占用任何磁盘空间。直到后续某个时点,在文件空洞中写入了数据,文件系统才会为之分配磁盘块。文件空洞的主要优势在于,与为实际需要的空字节分配磁盘块相比,稀疏填充的文件会占用较少的磁盘空间。
特殊用法
fcntl()
fcntl()
系统调用对一个打开的文件描述符执行一系列的控制操作,它的用法如下:
1 |
|
其中,fd
指的是文件描述符,cmd
用于设置这一函数的功能,而后面的省略号表示参数会根据cmd
的具体类型而定。返回值为-1表示失败,如果成功的话,返回值会因cmd
而异。下面为fcntl
的一些使用示例。
例1:可以使用fcntl()
来获取一个打开文件的访问模式和状态标志。方法如下:
1 | int flags=fcntl(fd, F_GETFL); |
后续可以使用flags
参数通过位运算来检查一些状态标志,例如:
1 | flags & O_SYNC //测试文件是否以同步写方式打开 |
例2:同样地,也可以修改一个打开文件的某些状态标志,允许更改的标志有:O_APPEND
、O_NONBLOCK
、O_NOATIME
、O_ASYNC
、O_DIRECT
。修改方式如下:
1 | int new_flags; |
例3:可以用fcntl()
复制文件描述符,用法为:
1 | int newfd = fcntl(oldfd, F_DUPFD, startfd); |
其中,oldfd
指的是要复制的文件描述符,startfd
为文件描述符的副本进行编号限制,将使用不小于startfd
的最小未用值作为描述符的编号。
此外,另一系统调用dup3
也可以完成这一功能,用法如下:
1 |
|
如果成功则返回新的文件描述符,失败返回-1。
特定偏移量的I/O
系统调用pread()
和pwrite()
可以完成与read()
和write()
相类似的工作,但是前两者会在offset
参数所指定的位置进行操作,而不是从当前的文件偏移量处,且不改变文件的当前偏移量。用法如下:
1 |
|
分散输入和集中输出
系统调用readv()
和writev()
实现了分散输入和集中输出的功能,用法如下:
1 |
|
上述两个系统调用一次可传输多个缓冲区的数据,数组iov
定义了一组用来传输数据的缓冲区,iovcnt
定义了iov
数组的长度。iovec
的数据结构如下:
1 | struct iovec{ |
readv()
从文件描述符中读取一片连续的字节,然后将其分散放置在iov
指定的缓冲区中,从第一个缓冲区开始,依次填满所有的缓冲区。如果数据不足以填充所有缓冲区,则只会占有部分。
writev()
将iov
指定的所有缓冲区的内容拼接起来,然后以连续字节序列写入文件描述符指代的文件中。
如果想在指定的文件偏移量处执行分散输入/集中输出,则可以使用下面两个函数:
1 |
|
文件截断
下列两个系统调用可以将文件大小设置为length
参数指定的值:
1 |
|
如果成功,则函数的返回值为0;失败则返回-1。当文件长度大于length
时,调用会丢弃超出部分,但如果小于参数length
,则调用将会在文件尾部添加一系列空字节或者一个文件空洞。
对于truncate()
函数而言,它需要以路径名字符串来指定文件,并要求文件可访问,且对文件具有写权限。如果文件名为符号连接,则会对其解引用;而调用ftruncate()
函数之前,需要以可写方式打开文件,获取其文件描述符,这一系统调用不会修改文件的偏移量。
/dev/fd
目录
对于每个进程,内核都提供了一个特殊的虚拟目录/dev/fd
,该目录中包含/dev/fd/n
形式的文件名,其中n
是与进程中的打开文件相对应的编号。打开/dev/fd
目录中的一个文件就等同于复制相应的文件描述符,因此下面两行代码等价:
1 | fd=open("/dev/fd/1",O_WRONLY); |
需要注意的是,如果使用open()
函数打开文件,则需要将其设置为与原描述符相同的访问模式,此时如果在flag
标志的设置中引入其它标志是无意义的,系统会自动忽略。
创建临时文件
有些程序需要创建一些临时文件,仅供其在运行期间使用,程序终止后立即删除。有两个函数可以用于创建临时文件:
1 |
|
如果成功则返回一个文件描述符,失败则返回-1。其中参数template
采用路径名的形式,最后6个字符必须为XXXXXX
,这6个字符将会被替换,以保证文件名的唯一性,而且修改后的字符串将会被保存到template
参数中。因此,template
参数被设置为字符数组,而不是字符串常量。
1 |
|
tmpfile()
函数会创建一个名称唯一的临时文件,并以读写方式打开。这一函数执行成功之后,将会返回一个文件流供stdio
库函数使用。文件流关闭之后将自动删除临时文件。
文件I/O缓冲
内核缓冲
read()
和write()
系统调用在操作磁盘文件时并不会直接发起磁盘访问,而是在用户空间缓冲区与内核缓冲区高速缓存之间复制数据。因此,写操作会先将用户空间内存传递到内核空间的缓冲区,然后在后续某个时刻再将缓冲区的数据刷写到磁盘中。如果在此期间另一进程试图读取文件的数据,内核将自动从缓冲区而不是文件中读取;而对于读操作,内核从磁盘中读取数据并存储到内核缓冲区,然后从缓冲区读取数据,直到把缓冲区的数据读完。对于序列化的文件访问,内核通常会执行预读以加快读取速度。
如果与文件发生大量的数据传输,通过采用大块空间缓冲数据,以及执行更少的系统调用,可以极大地提高I/O性能。
有时,我们需要控制文件I/O内核缓冲。fsync
系统调用将使得缓冲数据与打开文件描述符fd
相关的所有元数据都刷新到磁盘上。调用这一函数会强制使得文件处于同步I/O完成的状态,此时读请求的文件数据已经从磁盘传递给了进程,而写请求所指定的数据已经传递给磁盘,且用于获取数据的所有文件元数据以及所有发生更新的文件元数据也已经传递完毕。函数调用方式如下:
1 |
|
这一函数只有在对磁盘设备的传递完成之后才会返回。
另一个系统调用是fdatasync
,它的运作类似于fsync
,但是对于文件的元数据来说,只要求获取数据的所有文件元数据传递完毕:
1 |
|
fdatasync
可能会减少磁盘操作的次数,因为在这一函数的调用过程中,部分元数据的改变无需进行更新。而fsync
则会强制将元数据也传递到磁盘上。对于某些对性能要求较高,但是对某些元数据准确性要求不高的应用,便可以通过这种方式减少磁盘操作次数。
而sync
系统调用则会使包含更新文件信息的所有内核缓冲区(数据块、指针块、元数据等)刷新到磁盘上:
1 |
|
在调用open
函数时,指定O_SYNC
标志也会使得后续每次调用write
时都会自动将文件数据和元数据刷新到磁盘上。
需要特别注意的是,采用O_SYNC
标志(或者频繁调用fsync()
、fdatasync()
或sync()
)对性能的影响极大,会使得写入速度大大增加,尤其是当缓冲区的大小较低的时候。因此,如果需要强制刷新内核缓冲区,在设计应用程序的时候就应该考虑是否可以使用大尺寸的write()
缓冲区。
stdio库的缓冲
C语言的stdio函数库可以避免自行处理对数据的缓冲,一些相关的函数调用如下。
设置缓冲模式
1 |
|
在打开一个文件流之后,setvbuf
函数必须在调用任何其他stdio函数之前调用。这一调用将会影响后续在指定流上的所有stdio操作。其中各个参数的含义如下:
stream
:指定要修改的文件流buf
:针对于参数stream
要使用的缓冲区,如果值为NULL
,那么stdio库会为其自动分配一个缓冲区;如果不为NULL
,则使用指向size
大小的内存块作为缓冲区mode
:指定缓冲类型,具有下列值之一:_IONBF
:不对I/O进行缓冲,每个stdio库函数立即调用write()
和read()
函数,并忽略buf
和size
参数。stderr
默认为这一类型_IOLBF
:采用行缓冲I/O,指代终端设备的流默认属于这一类型。对于输出流,在输出一个换行符之前将缓冲数据;而对于输入流则每次读取一行数据_IOFBF
:采用全缓冲I/O,单次读写数据的大小与缓冲区相同。指代磁盘的流默认采用这一方式
setbuf
函数构建于setvbuf
之上,执行了类似任务:
1 |
|
在setbuf
函数中,参数buf
可以被设置为NULL
表示无缓冲,也可以被设置为指向由调用者分配的BUFSIZ
个字节大小的缓冲区。BUFSIZ
参数定义于<stdio.h>
头文件中。
setbuffer
函数类似于setbuf
函数,但是允许调用者指定buf
缓冲区大小:
1 |
|
刷新stdio缓冲区
无论当前采用哪一种缓冲区模式,在任何时候都可以使用fflush
库函数强制将stdio输出流中的数据刷新到内核缓冲区中,用法如下:
1 |
|
如果参数stream
为NULL
,则fflush
函数将刷新所有的stdio缓冲区。也可以将这一函数应用于输入流,这将丢弃已经缓冲的输入数据。
stdio库与系统调用混合
在同一文件上执行I/O操作时,还可以将系统调用和标准C语言库函数混合使用,下面两个函数有助于完成这一工作:
1 |
|
二者的功能相反,fileno
函数是给定一个文件流,然后返回相应的文件描述符,之后便可在I/O系统调用中正常使用该文件描述符;而fdopen
则给定一个文件描述符,然后创建一个使用该描述符进行文件I/O的相应流,mode
参数与fopen
中的含义相同,如果该参数与fd
的访问模式不一致则会失败。
fdopen
函数对于非常规的文件描述符很有用,借助这一函数便可在套接字、管道等文件类型上使用stdio库函数。
总结
stdio函数库和内核所采用的缓冲机制可以总结为下图:
文件属性
获取文件信息
使用如下几个系统调用,可以获取与文件有关的信息,其中大部分都提取自文件的i节点:
1 |
|
对于stat
和lstat
,无需对其所操作的文件本身拥有任何权限,但是对于指定路径的父目录要有执行(搜索)权限。
上述调用都会在缓冲区中返回一个由statbuf
指向的stat
结构,这个结构中包含了设备ID、i节点号、文件所有权、文件类型及权限、文件大小、已分配块、文件时间戳等信息。
文件时间戳
stat
结构的st_atime
,st_mtime
和st_ctime
字段为文件的时间戳,分别记录了文件的上次访问时间、上次修改时间、文件状态(即i节点内的信息)上次发生变更的时间。对时间戳的记录形式为自1970年1月1日以来经历的秒数。
一些系统调用可以用来修改时间戳:
1 |
|
文件所有权
每个文件都有一个与之管理的用户ID和组ID,据此可以判定文件所属的用户和组。当一个文件被创建时,其用户ID取进程的有效用户ID,而组ID则曲子进程的有效组ID或父目录的组ID。
下面的系统调用可以修改文件所有权:
1 |
|
文件权限
对于普通文件,stat
结构中的st_mod
字段低12位定义了文件权限,其中前3位为专用位,分别为set-user-ID位、set-group-ID位和sticky位,其余9位构成了定义权限的掩码,分别授予访问文件的各类用户(文件所有者、文件所属组、其它用户)的权限(可以读取文件内容、可以更改文件内容、可以执行文件)。
头文件<sys/stat.h>
包含了用来表示文件权限位的常量,如下图所示:
备注1—Linux进程的ID
实际用户ID和实际组ID:登录shell从
/etc/passwd
文件中读取相应用户密码记录的第三字段和第四字段,置为其实际用户ID和实际组ID,当创建一个新的进程时(如shell执行一个程序),将从父进程继承这些ID有效用户ID和有效组ID:当进程尝试执行各种操作(系统调用)时,将结合有效用户ID、有效组ID与辅助组ID一起确定授予进程的权限。通常,有效用户ID和有效组ID与相应的实际ID相同,但是也可以通过一些系统调用去修改有效用户ID和有效组ID
备注2—文件权限专用位的含义
set-user-ID位:如果这一位被设置,那么其它用户在执行该程序时,进程会拥有与程序文件属主相同的权限,即有效用户ID会被改变
set-group-ID位:与set-user-ID位的作用类似,但是修改的是有效组ID
sticky位:作用于目录时,起限制删除的作用。如果目录设置了该位,则表明仅当非特权进程拥有对目录的写权限,且为文件或者目录的属主时,才能对目录下的文件进行删除和重命名操作。
而对于目录来说,其权限方案与普通文件类似,但是权限的含义有不同。目录的读权限可以列出目录中的内容,写权限允许在目录内创建或者删除文件,可执行权限允许访问目录中的文件。
Linux内核会根据进程的有效用户ID、有效组ID和辅助组ID来进行权限检查。检查文件权限时,内核所遵循的规则如下:
- 对于特权级进程,授予其所有访问权限。
- 若进程的有效用户ID与文件的用户ID(属主)相同,内核会根据文件的属主权限,授予进程相应的访问权限。比方说,若文件权限掩码中的属主读权限(owner-read permission)位被置位,则授予进程读权限。否则,则拒绝进程对文件的读取操作。
- 若进程的有效组ID或任一附属组ID与文件的组ID属组)相匹配,内核会根据文件的属组权限,授予进程对文件的相应访问权限。
- 若以上三点皆不满足,内核会根据文件的other(其他)权限,授予进程相应权限。
而系统调用access
便可以根据当前进程的真实用户ID和组ID来检查文件的访问权限:
1 |
|
如果pathname
为符号引用,则函数将对其做解引用。参数mode
为位掩码,通过下面四个常量的或运算组合而成:
F_OK
:该文件是否存在R_OK
:是否对该文件有读权限W_OK
:是否对该文件有写权限X_OK
:是否对该文件有执行权限
对于新建文件,内核会使用open()
或者creat()
中的mode
参数所指定的权限;对于新建目录则会根据mkdir()
的mode
参数来设置权限。但是文件模式创建掩码umask
会对这些设置进行修改,它是一种进程属性,当进程新建文件或者目录时,该属性用于指明应该屏蔽哪些权限位。
进程的umask
通常继承自父shell,大多数shell的初始化文件会将umask
默认设置为八进制值022,也就是对于同组或者其它用户总是屏蔽写权限。系统调用umask()
可以将进程的umask
改为指定的值,用法如下:
1 |
|
对这一函数的调用总会成功,并返回进程的前一个umask
。
而下面两个系统调用可以直接修改文件的权限:
1 |
|
其中,mode
参数可以是八进制的数字形式(如0777),也可以是各个权限位或运算的掩码。要想改变文件权限,进程要么具有特权级别,要么其有效用户ID与文件的用户ID相匹配。而pathname
对应于要修改权限的文件,如果这一参数指向一个符号链接,那么调用chmod
则会改变符号链接所指代文件的访问权限,而不是符号链接自身的访问权限(符号链接的所有权限为所有用户共享,且不得更改)。
为了满足对特定用户和组授权时进行更为精密的控制这一需求,Linux内核支持使用访问控制列表(ACL)对文件权限模型进行扩展。利用ACL,可以在任意数量的用户和组之中,为单个用户或组指定文件权限。
一个ACL由一系列ACL记录(ACE)组成,每一条记录都针对单个用户或者用户组定义了对文件的访问权限。例如:
标记类型(表示该记录作用于一个用户、组还是其它类别的用户) | 标记限定符(用于标识特定的用户或组,即某个用户ID或者组ID) | 权限(为文件授予的权限信息) |
---|---|---|
ACL_USER_OBJ(文件属主的权限) | - | rwx |
ACL_USER(授予某个用户ID的权限) | 1007 | r-- |
ACL_GROUP_OBJ(文件组的权限) | - | rwx |
ACL_GROUP(授予某个组ID的权限) | 103 | -w- |
ACL_MASK(记录可以授予的最高权限) | - | rw- |
ACL_OTHER(不匹配其它ACE的用户的权限) | - | r-- |
扩展属性
文件的扩展属性(EA)指的是以名称-值对的形式,将任意元数据与文件的i节点关联起来的技术。EA的命名格式为namespace.name
,其中namespace
用于把EA从功能上划分为不同的大类,而name
则用于在命名空间中唯一标识某个EA。
可供namespace
使用的值包括:
user
:在文件权限检查的制约下,由非特权级进程操控。它只能施加在文件或者目录上。要获取user EA的值,需要有读权限;而如果要修改user EA的值,则要求有写权限。trusted
:可以由用户进程操控,但是进程必须具有特权。system
:供内核使用,将系统对象与文件关联,目前仅支持访问控制列表。security
:存储服务于操作系统安全模块的文件安全标签,也用于将可执行文件的能力关联起来。
一个i节点可以拥有多个相关的EA,其所从属的命名空间可以相同也可以不同,在各个命名空间内的EA名均自成一体。在user
和trusted
两个命名空间中,EA名可以为任意字符串;而在system
内,只有经内核明确认可的命名才可以使用。
在shell中,可以使用命令setfattr
和getfattr
来设置和查看文件的EA。同时,也有一系列的系统调用可以用来对文件的EA进行操作:
1 |
|
虚拟内存
内存映射
概述
内存映射分为文件映射和匿名映射。文件映射将一个文件的一部分直接映射到调用进程的虚拟内存中。一旦一个文件被映射之后,就可以通过在相应的内存区域中操作字节来访问文件内容。映射的分页会在需要的时候从文件中(自动)加载。这种映射也被称为基于文件的映射或内存映射文件。而对于匿名映射来说,一个匿名映射没有对应的文件。相反,这种映射的分页会被初始化为 0。
一个进程映射中的内存可以与其他进程中的映射共享(即各个进程的页表条目指向RAM中的相同分页)。当两个或更多个进程共享相同分页时,每个进程都有可能会看到其他进程对分页内容做出的变更,这取决于映射是私有的还是共享的。对于私有映射来说,在映射内容上发生的变更对其它进程不可见;而对于共享映射来说,在映射内容上发生的变更对所有共享同一个映射的其他进程都可见。
这两种映射特性组成了四种不同的内存映射:
- 私有文件映射:映射的内容被初始化为一个文件区域中的内容。这种映射的主要用途是使用一个文件的内容来初始化一块内存区域,例如根据二进制可执行文件的相应部分来初始化一个进程的文本和数据段。
- 私有匿名映射:每次调用
mmap()
创建一个私有匿名映射时都会产生一个新映射,该映射与同一(或不同)进程创建的其他匿名映射是不同的,它们不会共享物理分页。 - 共享文件映射:所有映射一个文件的同一区域的进程会共享同样的内存物理分页,这些分页的内容将被初始化为该文件区域。对映射内容的修改将直接在文件中进行。这种映射方式允许内存映射I/O,同时允许无关进程共享一块内容以便进行进程间通信。
- 共享匿名映射:每次调用
mmap()
创建一个共享匿名映射时都会产生一个新的、与任何其他映射不共享分页的新映射。映射的分页不会被写时复制,并且一个进程对映射内容所做出的变更会对其他进程可见。共享匿名映射允许以一种类似于System V 共享内存段的方式来进行IPC,但仅限于相关进程之间。
创建映射
mmap()
系统调用在调用进程的虚拟地址空间中创建一个新的映射:
1 |
|
addr
参数指定了映射被放置的虚拟地址,可以指定为NULL使内核为映射选择一个合适的地址。length
参数指定了映射的字节数,会被向上提升为分页大小的整数倍。prot
为一个位掩码,取值可以为PROT_NONE,或者PROT_READ、PROT_WRITE和PROT_EXEC的组合。flags
参数是一个控制映射操作各个方面选项的位掩码,可以为MAP_PRIVATE和MAP_SHARED之一,也可以同时用或操作包含其它值,如MAP_ANONYMOUS、MAP_FIXED、MAP_LOCKED等。fd
和offset
用于文件映射,fd
参数为被映射文件的文件描述符,offset
参数指定映射在文件中的起点,必须是系统分页大小的倍数。
解除映射
munmap()
系统调用执行的是与mmap()
相反的操作,即从调用进程的虚拟地址空间中删除一个映射:
1 |
|
addr
参数是待解除映射的地址范围的起始地址,它必须与一个分页边界对齐。length
是一个非负整数,它指定了待解除映射区域的大小。在解除映射时,可以解除整个映射,也可以解除一个映射中的一部分。
当一个进程终止或执行了一个exec()
之后进程中所有的映射会自动被解除。
文件映射
要创建一个文件映射需要执行下面的步骤。
- 获取文件的一个描述符,通常通过调用
open()
来完成。 - 将文件描述符作为
fd
参数传入mmap()
调用。
执行上述步骤之后,mmap()
会将打开的文件的内容映射到调用进程的地址空间中。一旦mmap()
被调用之后,就能够关闭文件描述符,而不会对映射产生任何影响。
匿名映射
在Linux中,使用mmap()
创建匿名映射有两种办法。一种是在flags
指定MAP_ANONYMOUS 并将fd
设置为-1,另一种是打开/dev/zero
设备文件,并将得到的文件描述符传递给mmap()
。此时,得到的映射中的字节会被初始化为0,offset 参数会被忽略。
同步映射区域
内核会自动将发生在MAP_SHARED映射内容上的变更写入到底层文件中,但在默认情况下,内核不保证这种同步操作会在何时发生。msync()
系统调用让应用程序能够显式地控制何时完成共享映射与映射文件之间的同步:
1 |
|
addr
和length
参数指定了需同步的内存区域的起始地址和大小。在addr
中指定的地址必须是分页对齐的,length
会被向上舍入到系统分页大小的下一个整数倍。flags
参数可以为MS_SYNC(会阻塞直到内存区域中所有被修改过的分页被写入到硬盘为止)、MS_ASYNC(内存区域仅与内核高速缓冲区同步)和MS_INVALIDATE(使映射数据的缓存副本失效)其中之一。
重新映射
Linux提供了mremap()
系统调用,使得映射的位置和大小可以被改变:
1 |
|
old_address
和old_size
参数指定了需扩展或收缩的既有映射的位置和大小。在old_address
中指定的地址必须是分页对齐的,并且通常是一个由之前的mmap()
调用返回的值。映射预期的新大小会通过new_size
参数指定。在old_size
和new_size
中指定的值都会被向上舍入到系统分页大小的下一个整数倍。
在执行重映射的过程中内核可能会为映射在进程的虚拟地址空间中重新指定一个位置,而是否允许这种行为则是由flags
参数来控制的。它是一个位掩码,其值要么是0,要么为MREMAP_MAYMOVE或者MREMAP_MAYMOVE|MREMAP_FIXED(此时需要额外传入一个参数void* new_address
,将映射迁移至这个指定地址)
虚拟内存操作
改变内存保护
mprotect()
系统调用修改起始位置为addr
(必须是分页大小整数倍),长度为length
(会被向上舍入到分页大小整数倍)字节的虚拟内存区域中对分页的保护:
1 |
|
prot
参数是一个位掩码,它指定了这块内存区域上的新保护,其取值是PROT_NONE或PROT_READ、PROT_WRITE以及PROT_EXEC这三个值中的一个或多个取或操作。如果一个进程在访问一块内存区域时违背了内存保护,那么内核就会向该进程发送一个SIGSEGV信号。
内存锁
在一些应用程序中,将一个进程的虚拟内存的部分或全部锁进内存以确保它们总是位于物理内存中,可以提高程序的性能,并且保证敏感数据不会被交换到磁盘上。
特权进程能够锁住的内存数量是没有限制的(即RLIMIT_MEMLOCK会被忽略);非特权进程能够锁住的内存数量上限由软限制RLIMIT_MEMLOCK定义。由于虚拟内存的管理单位是分页,因此内存加锁会应用于整个分页。在执行限制检查时,RLIMIT_MEMLOCK限制会被向下舍入到最近的系统分页大小的整数倍。
对内存区域加锁和解锁的操作如下:
1 |
|
除了显式地使用munlock()
之外,内存锁在下列情况下会被自动删除:
- 进程终止
- 被锁住的分页通过
munmap
被解除映射 - 被锁住的分页通过
mmap
MAP_FIXED标记的映射覆盖时
一个进程可以给它占据的所有内存加锁和解锁:
1 |
|
其中flags
参数可以为MCL_CURRENT、MCL_FUTURE以及二者的或。MCL_CURRENT将调用进程虚拟地址空间中当前所有映射的分页锁进内存,MCL_FUTURE将后续映射进调用进程的虚拟地址空间的所有分页锁进内存。
确定内存驻留性
mincore()
系统调用是内存加锁系统调用的补充,它报告在一个虚拟地址范围中哪些分页当前驻留在RAM 中,因此在访问这些分页时也不会导致分页故障:
1 |
|
这一系统调用返回起始地址为addr
,长度为length
字节的虚拟地址范围中分页的内存驻留信息。addr
中的地址必须是分页对齐的,并且由于返回的信息是有关整个分页的,因此length
实际上会被向上舍入到系统分页大小的下一个整数倍。内存驻留的相关信息会通过vec
返回。
建议后续的内存使用模式
madvise()
系统调用通过通知内核调用进程对起始地址为addr
长度为length
字节的范围之内分页的可能的使用情况,来提升应用程序的性能。内核可能会使用这种信息来提升在分页之下的文件映射上执行的I/O的效率。
1 |
|
参数addr
的值要求分页对齐,length
会被向上舍入到系统分页大小的下一个整数倍,advice
可以为下面几个值其中之一:
- MADV_NORMAL:默认行为,分页以簇(一个系统分页大小整数倍)传输,这样会导致一些预先读和事后读
- MADV_RANDOM:这个区域的分页会被随机访问,这样预先读不会带来任何好处
- MADV_SEQUENTIAL:在此范围内的分页只会被访问一次,并且是顺序访问
- MADV_WILLNEED:预先读取这个区域中的分页以备将来的访问之需
- MADV_DONTNEED:调用进程不再要求这个区域中的分页驻留在内存中
定时器
间隔定时器
系统调用setitimer
会创建一个间隔式定时器,它会在未来的某个时间点到期,(可选)并于此后每隔一段时间到期一次:
1 | #include<sys/time.h> |
which
参数可以设置为下面3个值的其中一个:
- ITIMER_REAL:创建以真实时间倒计时的定时器,到期时产生SIGALARM信号
- ITIMER_VIRTUAL:创建以进程虚拟时间(用户模式下的CPU时间)倒计时的定时器,到期产生SIGVTALRM信号
- ITIMER_PROF:创建一个profiling定时器,以进程时间(用户态与内核态CPU时间总和)倒计时,到期产生SIGPROF信号
对上述所有这些信号的默认处置均会终止进程。
参数new_value
和old_value
均为指向结构itimerval
的指针,其定义如下:
1 | struct itimerval{ |
一个进程只能拥有上述三类计时器的各一个。当第二次调用setitimer
时,修改已有定时器的属性要符合参数which
中的类型。如果调用setitimer()
时将new_value.it_value
的两个字段均置为0,那么会屏蔽任何已有的定时器。
参数new_value
的下属结构it_value
指定了距离定时器到期的延迟时间。另一下属结构it_interval
则说明该定时器是否为周期性定时器。如果it_interval
的两个字段值均为0,那么该定时器就属于在it_value
所指定的时间间隔后到期的一次性定时器。只要it_interval
中的任一字段非0,那么在每次定时器到期之后,都会将定时器重置为在指定间隔后再次到期。
如果参数old_value
不为NULL,则以其所指向的interval
结构来返回定时器的前一个设置。如果old_value.it_value
的两个字段值均为0,那么该定时器之前处于屏蔽状态;如果old_value.it_interval
的两个字段值均为0,那么该定时器之前被设置为一次性定时器。如果不关心定时器的前一个设置,可以将old_value
设置为NULL。
定时器会从初始值it_value
开始倒计时至0为止。递减至0时,会有相应的信号发送给进程。随后,如果时间间隔值it_interval
非0,则会再次将it_value
加载至计时器,重新开始向0倒计时。
可以在任何时刻调用getitimer()
,以了解定时器的当前状态:
1 | #include<sys/time.h> |
一个更加简单的定时器接口是alarm()
系统调用:
1 | #include<unistd.h> |
其中,参数seconds
表示定时器到期的秒数。到期时会向调用进程发送SIGALRM信号。调用alarm
会覆盖对定时器的前一个设置,而调用alarm(0)
可屏蔽现有定时器。它的返回值是定时器的前一个设置距离到期的剩余秒数,如果未设置定时器则返回0。
取决于当前负载和对进程的调度,系统可能会在定时器到期的瞬间(通常是几分之一秒)之后才去调度其所属进程。但是后续定时器的调度会严格遵守其设置的时间间隔。而且定时器的精度受制于软件时钟的频率,如果定时器值未能与软件时钟间隔的倍数严格匹配,那么定时器值则会向上取整。
休眠
有时需要将进程挂起一段时间,此时可以使用休眠函数来实现:
1 | #include<unistd.h> |
POSIX时钟
POSIX时钟所提供的时钟访问API可以支持纳秒级的时间精度,在Linux中调用此API的程序必须以-lrt
选项进行编译,从而与librt
函数库相链接。POSIX时钟API的三个主要系统调用如下:
1 | #include<time.h> |
clockid_t
数据类型用于表示时钟标识符,一共有四种类型:
- CLOCK_REALTIME:可设定的系统级实时时钟
- CLOCK_MONOTONIC:不可设定的恒定态时钟
- CLOCK_PROCESS_CPUTIME_ID:每进程CPU时间的时钟
- CLOCK_THREAD_CPUTIME_ID:每线程CPU时间的时钟
POSIX间隔式定时器
setitimer()
设置的经典UNIX间隔式定时器存在一些制约,而POSIX定时器可以突破这些限制。在Linux中调用此API的程序必须以-lrt
选项进行编译,从而与librt
函数库相链接。POSIX定时器的生命周期分为三个阶段:创建、启动/停止、删除。
相关的API如下:
1 | #include<signal.h> |
文件描述符定时器
Linux特有的timerfd API可以从文件描述符中读取其所创建定时器的到期通知。相关的系统调用包括:
1 | #include<sys/timerfd.h> |
一旦以timerfd_settime()
启动了定时器,就可以从相应文件描述符中调用read()
来读取定时器的到期信息。出于这一目的,传给read()
的缓冲区必须足以容纳一个无符号8字节整型(uint64_t
)数。在上次使用timerfd_settime()
修改设置以后,或是最后一次执行read()
后,如果发生了一起到多起定时器到期事件,那么read()
会立即返回,且返回的缓冲区中包含了已发生的到期次数。如果并无定时器到期,read()
会一直阻塞直至产生下一个到期。
可以利用select()
、poll()
和epoll()
对timerfd
文件描述符进行监控。如果定时器到期,会将对应的文件描述符标记为可读。
进程
基础内容
进程号
每个进程都有一个进程号(PID),它是一个正数,用来唯一标识系统中的某个进程。对于各种系统调用而言,进程号有时候可以作为传入参数,有时候可以作为返回值。
系统调用getpid
返回调用进程的进程号:
1 |
|
getpid()
返回值的数据类型是pid_t
,这一数据类型被专门用来存储进程号。创建一个新的进程时,内核会按顺序将下一个可用的进程号分配给新进程使用;而当进程号达到最大值的限制时,内核将重置进程号计数器至300(因为低数值的进程号通常被系统进程和守护进程长期占用,故之间跳过这一区域)。Linux的最大进程号由内核常量PID_MAX
所定义。
每个进程都有一个创建自己的父进程,系统调用getppid
可以检索父进程的进程号:
1 |
|
所有进程的始祖是1号进程init
,在Linux的命令行输入pstree 1
即可看到系统中进程的家族树结构。
进程的内存布局
在Linux系统中,进程的内存被布局到虚拟内存中。每个进程所分配的虚拟内存由很多部分组成,每个部分称为“段”,如下图所示:
每个部分的含义如下:
- 文本段:包含进程运行的程序机器语言指令。为了防止进程通过错误指针修改指令,这段内存被设置为只读;同时因为多个进程可以运行同一程序,故这一段内存可共享,这样便可将程序代码映射到多个进程的虚拟地址空间中。
- 初始化数据段:包含显式初始化的全局变量和静态变量。当程序加载到内存时,从可执行文件中读取这些变量的值。
- 未初始化数据段:包含未进行显式初始化的全局变量和静态变量,程序启动之前,这段内存的所有值被初始化为0。
- 栈:动态增长和收缩的段,由栈帧组成,系统会为每一个当前调用的函数分配一个栈帧。栈帧中存储了函数的局部变量、实参和返回值。
- 堆:可以在运行时为变量动态分配内存的一块区域。
虚拟内存的规划之一是将每个程序使用的内存切割成小型的、固定大小的“页”(page)单元。相应地,将RAM 划分成一系列与虚拟内存页尺寸相同的页帧。任一时刻,每个程序仅有部分页需要驻留在物理内存页帧中。这些页构成了所谓驻留集(resident set)。程序未使用的页拷贝保存在交换区(swap area)内(磁盘空间中的保留区域,作为计算机RAM 的补充),仅在需要时才会载入物理内存。若进程欲访问的页面目前并未驻留在物理内存中,将会发生页面错误(page fault),内核即刻挂起进程的执行,同时从磁盘中将该页面载入内存。由于程序访问的空间和时间局部性特征,程序即便仅有部分地址空间存在于RAM 中,依然可能得以执行。
为支持这一组织方式,内核需要为每个进程维护一张页表(page table)。该页表描述了每页在进程虚拟地址空间(virtual address space)中的位置(可为进程所用的所有虚拟内存页面的集合)。页表中的每个条目要么指出一个虚拟页面在RAM中的所在位置,要么 表明其当前驻留在磁盘上。
在进程虚拟地址空间中,并非所有的地址范围都需要页表条目。通常情况下,由于可能存在大段的虚拟地址空间并未投入使用,故而也无必要为其维护相应的页表条目。若进程试图访问的地址并无页表条目与之对应,那么进程将收到一个SIGSEGV信号。
使用虚拟内存的优点包括:
- 进程与进程,进程和内核互相隔离
- 适当情况下,两个或者多个进程可以共享内存
- 通过对页表条目的标记,方便实现内存保护机制
- 无需关注程序在RAM的物理布局
- 一个进程所使用的虚拟内存大小可以超过RAM的容量
- RAM中可以容纳多个进程所使用的内存,从而提高CPU的利用率
栈和栈帧
函数的调用和返回使得栈的增长和收缩呈线性。栈驻留在内存地址的高端并向下增长,栈指针寄存器用于跟踪当前的栈顶。每次调用函数时,会在栈上新分配一帧,而当函数返回时再从栈上将此帧移除。由于函数能够嵌套调用,故栈中可能有多个栈帧。
每个用户栈的栈帧包括如下信息:
- 函数实参和局部变量,函数返回时这些变量会自动被销毁
- 函数调用的信息,每当一个函数调用另一个函数时,会在被调用函数的栈帧中保存当前寄存器的副本,以便函数返回时能为函数调用者将寄存器恢复原状
命令行参数
每个C语言程序都必须有一个main()
的主函数,作为程序启动的起点。当执行程序时,命令行参数通过两个入参提供给main
函数,第一个为int argc
,表示命令行参数的个数;第二个参数char *argv[]
,是一个指向命令行参数的指针数组,第一个字符串argv[0]
指向该程序的名称,最后一个元素argv[argc]
为NULL指针,其余参数指向其它的命令行参数。除了最后一个元素之外,其余指针指向的参数都以空字符结尾。
环境列表
每一个进程都有与之相关的环境列表,它是一个字符串数组,其中每个字符串都以名称=值(name=value)的格式定义。而列表中的名称也被称为环境变量。
新进程在创建时,会继承其父进程的环境副本。子进程只有在创建时才能获得其父进程的环境副本,故这一信息传递是单向、一次性的。子进程创建之后,父进程和子进程都可以修改各自的环境变量,而且这些变更对于对方而言都不再可见。
环境变量常见的用途之一是在shell中,通过在自身环境中放置变量值,shell便可以确保将这些值传递给所创建的进程。大多数shell使用export命令向环境中添加变量值。
备注:shell和bash
Shell是系统的用户界面,相当于操作系统的“外壳”,提供了用户与内核进行交互操作的一种接口。它接收用户输入的命令并把它送入内核去执行,是在Linux内核与用户之间的解释器程序。
而bash指的是Linux系统中的/bin/bash解释器,它负责向内核翻译以及传达用户/程序指令。而如果在shell脚本的第一行写 #!/bin/bash ,意思就是用 /bin/bash解释器去执行这个脚本。
通过Linux专有的/proc/PID/environ文件,可以检查任一进程的环境列表。而在C语言程序中,也可以使用全局变量char** environ
来访问环境列表。environ
和argv
参数类似,指向一个以NULL结尾的指针数组,而其它每个指针又指向一个以空字节终止的字符串。
如果要在环境列表里面检索单个值,可以用下面的函数:
1 |
|
这一函数接收一个环境变量名称name作为参数,返回相应的value,并以字符串指针的形式传值;如果不存在指定名称的环境变量则返回NULL。
如果要修改环境,可以使用的函数包括:
1 |
|
非局部跳转
库函数setjmp()
和longjmp()
可以执行非局部跳转,此处的非局部指的是跳转的目标位于当前执行函数之外的某个位置。这两个函数可以被用于下面的场景:在一个深层嵌套的函数调用中发生了错误,需要放弃当前任务,从多层函数调用中返回主函数。二者的用法如下:
1 |
|
setjmp
函数用于设置跳转点,在后面调用longjmp
时就会跳转到setjmp
的位置。从编程的角度来看,调用longjmp
之后,看起来和第二次调用setjmp
返回时完全一样。通过查看setjmp()
返回的整数值,可以区分setjmp
调用是初始返回还是第二次“返回”。初始调用返回值为0,后续“伪”返回的返回值为longjmp()
调用中val
参数所指定的任意值。通过对val
参数使用不同值,能够区分出程序中跳转至同一目标的不同起跳位置。
env
参数用于存储当前进程的信息,以及程序计数寄存器和栈指针寄存器的副本等信息,这些信息被用于恢复setjmp
处的程序执行状态,从而使得后续的程序可以被接着执行。在调用longjmp
时,需要传入与setjmp
相同的env
变量。由于两个函数的调用通常位于不同的函数,因此env
参数常常被设置为全局变量。
在实际工程中,应该尽可能地避免使用setjmp
和longjmp
这两个函数,因为它们会使得程序地复杂程度变高,使程序难以阅读和维护。
内存分配
在堆上分配内存
进程可以通过增加堆的大小来分配内存,通常将堆的当前内存边界称为“Program Break”。在程序开始运行的时候,Program Break位于未初始化数据段的末尾之后。而在Program Break的位置抬升之后,程序可以访问新分配区域内的任何内存地址,而此时物理内存页尚未分配。内核会在进程首次试图访问这些虚拟内存地址时自动分配新的物理内存页。
下面两个系统调用可以操纵Program Break:
1 |
|
brk
会将Program Break设置为参数end_data_segment
所指向的位置。由于虚拟内存以页为单位进行分配,因此end_data_segment
实际会四舍五入到下一个内存页的边界处。而sbrk
将Program Break在原有的位置上增加参数increment
表示的大小,intptr_t
属于整数数据类型,如果传入0则会返回当前Program Break的位置。
需要注意的是,如果试图将Program Break设置为一个低于其初始值的位置,则有可能会导致无法预知的行为。
C语言程序的malloc
函数被用于在堆上分配size
大小的内存,并返回指向新分配内存起始位置处的指针,其所分配的内存未初始化。这一函数的接口简单,更加方便在多线程程序中使用,且允许随意释放内存块。它的用法如下:
1 |
|
如果函数执行成功,则返回分配内存起始位置的指针,失败则返回NULL。由于函数的返回类型为void*
,因此可以将其赋值给任意类型的指针。同时malloc
返回的内存块已经基于8或者16字节进行内存对齐,从而适宜于高效访问不同类型的数据结构。
malloc()
的实现很简单。它首先会扫描之前由free()
所释放的空闲内存块列表,以求找到尺寸大于或等于要求的一块空闲内存。(取决于具体实现,采用的扫描策略会有所不同)如果这一内存块的尺寸正好与要求相当,就把它直接返回给调用者。如果是一块较大的内存,那么将对其进行分割,在将一块大小相当的内存返回给调用者的同时,把较小的那块空闲内存块保留在空闲列表中。
如果在空闲内存列表中根本找不到足够大的空闲内存块,那么malloc()
会调用sbrk()
以分配更多的内存。为减少对sbrk()
的调用次数,malloc()
并未只是严格按所需字节数来分配内存,而是以更大幅度(以虚拟内存页大小的数倍)来增加Program Break,并将超出部分置于空闲内存列表。
堆内存的释放可以使用free
函数:
1 |
|
free
函数将会释放ptr
参数所指向的内存块,它应当是之前由malloc
或者其它堆内存分配函数之一所返回的地址。
一般情况下,free
函数并不降低Program Break的位置,而是将这块内存添加到空闲内存列表中,供后续的malloc
函数循环使用。当malloc()
分配内存块时,会额外分配几个字节来存放记录这块内存大小的整数值。该整数位于内存块的起始处,而实际返回给调用者的内存地址恰好位于这一长度记录字节之后。当将内存块置于空闲内存列表(双向链表)时,free()
会使用内存块本身的空间来存放链表指针,将自身添加到列表中。
此外,还有一些在堆上分配内存的其他函数:
1 | void* calloc(size_t numitems, size_t size) |
这一函数用于给一组相同对象分配内存,其中numitems
指的是对象的个数,size
指的是每个对象的大小,已经分配的内存会被初始化为0。而函数的返回值则是一个指向这块内存起始处的指针。
1 | void* realloc(void* ptr, size_t size) |
这一函数用来调整一块内存的大小为size
,而ptr
为需要调整大小的内存块的指针,这块内存应该是之前malloc
函数分配的。而realloc
函数可能会重新分配一块内存,并将原来的数据复制到新的内存块,这将会占用大量的CPU资源。因此,应该尽量避免调用realloc
。
在栈上分配内存
由于栈帧也存在扩展空间,因此也可以通过增加栈帧的大小从栈上动态分配内存。alloca
函数可以实现这一功能,它的用法如下:
1 |
|
size
参数指定在堆栈上分配的字节数,函数的返回值为指向已分配内存块的指针。
由于编译器将alloca
作为内联代码处理,且直接通过调整堆栈指针来实现,加上无需维护空闲内存块列表,因此使用alloca
分配内存的速度快于malloc
。在使用时需要注意的是,alloca
函数分配的内存不需要也绝不能使用free
函数释放,当栈帧移除之后(即函数返回)这块内存空间将会被自动释放掉。
系统和进程信息
/proc
文件系统
概述
为了提供更加简便的方法来访问内核信息,许多现代的UNIX实现提供了一个/proc
虚拟文件系统。该文件系统驻留于/proc
目录中,包含了各种用于展示内核信息的文件,并且允许进程通过常规文件I/O系统调用来方便地读取,有时还可以修改这些信息。这一文件系统称为虚拟,是因为其包含的文件和子目录并未存储于磁盘上,而是由内核在进程访问此类信息时动态创建而成。
获取进程有关的信息
对于系统中的每个进程,内核都提供了相应的目录,命名为/proc/PID
,其中PID是进程的ID。在此目录中的各种文件和子目录包含了进程的相关信息,包括:
status
:包含了关于进程的一系列信息,如进程ID、凭证、内存使用量、信号等cmdline
:以\0
分割的命令行参数cwd
:指向当前工作目录的符号连接environ
:NAME=value键值对环境列表,用\0
分割exe
:指向正在执行文件的符号连接fd
:文件目录,包含了指向由进程打开文件的符号连接,例如/proc/1968/1
是ID为1968的进程中指向标准输出的符号连接maps
:内存映射mem
:进程的虚拟内存mounts
:进程的安装点root
:指向根目录的符号连接task
:包含了进程中的每个线程,每个线程都对应于一个子目录
系统信息
在/proc
目录下还包含了一些系统信息,例如:
/proc/net
:有关网络和套接字的状态信息/proc/sys/fs
:文件系统的相关设置/proc/sys/kernel
:各种常规的内核设置/proc/sys/net
:网络和套接字的设置/proc/sys/vm
:内存管理设置/proc/sysvipc
:有关System V IPC对象的信息
系统标识
系统调用uname()
返回了一系列关于主机系统的标识信息,用法如下:
1 |
|
其中,utsbuf
参数是一个指向utsname
的指针,它的定义如下:
1 | struct utsname{ |
进程的创建
系统调用fork()
可以创建一个新的进程,它的用法为:
1 |
|
这一系统调用执行完成之后,将会存在两个进程,这两个进程都会从fork()
的返回处继续往下执行。这两个进程执行相同的程序代码段,但是各自拥有不同的栈段、数据段和堆段拷贝。子进程的栈、数据和堆段开始时完全复制于父进程。而在此之后,每个进程可以修改各自的栈数据和堆段的变量,对另一进程完全没有影响。
程序代码可以通过fork()
的返回值来区分父进程和子进程。在父进程中,这一系统调用返回的时子进程的进程ID,而在子进程中则返回0。如果无法创建子进程,则父进程返回-1。
执行fork()
之后,子进程会获得父进程所有文件描述符的副本。因此这意味着父、子进程中对应的文件描述符会指向相同的打开文件句柄(其中含有当前文件偏移量、文件状态标志,这些属性在父子进程中共享)。因此如果子进程更新了文件偏移量,那么这也会影响到父进程中相应的描述符。
在执行fork()
之后,父进程和子进程执行的特定顺序是不确定的,如果要保证某一特定执行顺序,则需要使用一些同步技术如信号等。
进程的终止
API
进程的终止有两种方式,一种为异常终止,通过接收一个信号而引发;而另一个方式是使用_exit()
系统调用正常终止:
1 |
|
其中,status
参数定义了进程的终止状态,父进程可以调用wait()
来获取该状态。虽然它为int
数据类型,但是只有低8位可以被父进程使用。调用_exit()
的程序总是会成功终止。
但是程序一般不会直接调用_exit()
,而是调用库函数exit()
,它会在调用_exit()
之前执行各种动作:
1 |
|
exit()
系统调用执行的动作包括:
- 调用退出处理程序,即通过
atexit()
和on_exit()
注册的函数,其执行顺序和注册顺序相反。 - 刷新
stdio
流缓冲区 - 使用由
status
提供的值,执行_exit()
系统调用。
程序的另一种终止方法是从main()
函数中返回,或者一直执行到函数的结尾处。执行return n
其实等同于执行exit(n)
。如果main()
函数无返回值,则同样会执行exit()
函数,但是返回值与C语言的标准以及编译器选项有关。
进程终止的细节
在进程终止时,无论是正常还是异常终止,都会发生如下动作:
- 关闭所有打开的文件描述符、目录流、信息目录描述符和字符集转换描述符
- 释放进程持有的任何文件锁
- 分离任何已经连接的System V共享内存段,且对应于各段的
shm_nattch
计数器值将会减一 - 进程为每个System V信号量设置的
semadj
值将会被加到信号量值中 - 如果该进程是一个管理终端的管理进程,那么系统会向终端前台进程组中的每个进程发送SIGHUP信号,接着终端与会话脱离
- 关闭该进程打开的任何POSIX有名信号量
- 关闭该进程打开的任何POSIX消息队列
- 如果某进程组称为孤儿,且该组中存在任何已停止进程,则组中所有进程都会收到SIGHUP信号,随后收到SIGCONT信号
- 移除该进程通过
mlock()
或者mlockall()
建立的任何内存锁 - 取消该进程调用
mmap()
所创建的任何内存映射
退出处理程序
退出处理程序指的是由程序设计者提供的函数,在调用exit()
使得程序正常终止时会自动执行(异常终止或者调用_exit()
则不会调用)。注册退出处理程序的函数有两个:
1 |
|
可以注册多个退出处理程序,甚至将同一个函数注册多次。当应用程序调用exit()
时,这些函数的执行顺序与注册顺序相反。但是如果其中任意一个退出处理程序无法返回,那么就不再调用剩余的处理程序。
通过fork()
创建的子进程会继承父进程注册的退出处理函数。而进程调用exec()
时,会移除所有已注册的退出处理程序。
进程监控
等待子进程
对于需要创建子进程的程序来说,父进程可以监测子进程的终止时间和过程是很有必要的。系统调用wait()
等待调用进程的任意一个子进程终止,同时在参数status
所指向的缓冲区中返回该子进程的终止状态:
1 |
|
这一系统调用会执行如下的动作:
- 如果调用这一函数时没有任何子进程终止,则这一调用将一直阻塞,直到某个子进程终止;如果调用时已有子进程终止,则立即返回。需要注意的是,如果在同一时刻有多个子进程同时退出,
wait
处理它们的顺序没有任何的规定。 - 如果
status
非空,则关于子进程如何终止的信息会通过它指向的整型变量返回 - 内核将为父进程下所有子进程的运行总量追加进程CPU时间以及资源使用数据
- 将终止子进程的ID作为
wait()
的结果返回
出错时返回-1,可能的错误原因之一是调用进程并无已经终止的子进程,此时会将errno
设置为ECHILD。
系统调用wait()
存在很多限制,包括:
- 如果父进程已经创建了多个子进程,无法等待某个特定子进程的完成,只能按顺序等待下一个子进程的终止
- 如果没有子进程退出,则一直保持阻塞状态。有时会希望执行非阻塞的等待
- 只能发现那些已经终止的子进程,无法处理子进程因为某个信号而停止或者已停止子进程收到信号恢复执行的情况
为了解决这些限制,可以使用waitpid()
系统调用:
1 |
|
waitpid
与wait
的返回值和status
参数的含义相同。参数pid
表示需要等待的具体子进程,如果大于0则表示等待进程ID为pid
的子进程,如果等于0则等待与父进程同一个进程组的所有子进程,如果小于-1则等待进程标识符等于pid
绝对值的所有子进程,如果等于-1则等待任意子进程。参数options
是一个位掩码,可以按位或操作包含这些标志:WUNTRACED
(除返回终止子进程的信息外,还返回因信号而停止的子进程信息)、WCONTINUED
(返回那些收到SIGCONT信号而恢复执行的已停止子进程的状态信息)、WNOHANG
(如果参数pid
指定的子进程并未发生状态改变,则立即返回而不会阻塞,此时返回0。
由wait
或者waitpid
返回的status
值可以用来区分不同的子进程事件。有一组标准宏可以用来解析等待状态值:
1 |
|
在Linux系统中,也可以使用waitid
系统调用来等待子进程:
1 |
|
其中,idtype
和id
指定了需要等待的子进程,idtype
可以为下面三个参数中的其中一个:
P_ALL
:等待任何子进程,同时忽略id值P_PID
:等待进程ID为id进程的子进程P_PGID
:等待进程组ID为id各进程的所有子进程
waitid
可以更加精确地控制子进程,可以通过位运算指定如下的标识:
WEXITED
:等待已终止的子进程,无论其是否正常返回WSTOPPED
:等待已通过信号而停止的子进程WCONTINUED
:等待经信号SIGCONT恢复的子进程WNOHANG
:非阻塞调用WNOWAIT
:返回子进程状态,但子进程依然处于可等待状态,稍后可再次等待并获取相同信息
孤儿进程与僵尸进程
孤儿进程指的是父进程先于子进程结束的情况,此时孤儿进程将会由init
进程(进程ID为1,即所有进程始祖)来接管。
僵尸进程指的是在父进程执行wait
之前就已经终止的子进程。此时,内核会将子进程转为僵尸进程,这将会释放掉子进程使用的大部分资源供其它进程使用。子进程会在内核进程表中保留一条记录,其中包含子进程ID、终止状态、资源使用数据等信息。这确保了父进程总是可以执行wait
方法。当父进程执行完wait
之后,内核会自动删除僵尸进程;如果父进程结束之前没有调用wait
,那么init进程将会接管这些僵尸进程并删除。
因此,如果父进程创建了某一子进程且一直未退出,但是未执行wait
,在内核的进程表中将为这一子进程永久保留一条记录,如果存在大量的僵尸进程,它们将会填满内核进程表,从而阻碍新进程的创建。在设计长生命周期的父进程(如网络服务器和shell)时要特别注意这一点。
SIGCHLD信号
前面介绍的系统调用将会导致阻塞或者轮询,从而造成了CPU资源浪费,并增加了应用程序复杂度。为了规避这些问题,可以采样针对SIGCHLD信号的处理程序。
无论一个子进程于何时终止,系统都会向其父进程发送SIGCHLD 信号。对该信号的默认处理是将其忽略,不过也可以使用信号处理程序来捕获它。还有另一种移植性稍差的处理方法,进程可选择将对SIGCHLD 信号的处置置为忽略(SIG_IGN
),这时将立即丢弃终止子进程的状态(因此其父进程从此也无法获取到这些信息),子进程也不会成为僵尸进程。
程序执行
执行新程序
系统调用execve()
可以将新程序加载到某一进程的内存空间。在这一操作过程中,将会丢弃掉旧的程序,而进程的栈、数据和堆段也将会被新程序的对应部分替换。在执行了各种C语言函数库的运行时启动代码和程序的初始化代码之后,将会从新程序的main()
函数处开始执行。这一系统调用的使用方法如下:
1 |
|
其中,参数pathname
包含了准备载入当前进程空间新程序的路径名,可以为绝对或者相对路径;参数argv
用来给程序传递命令行参数;最后一个参数envp
指定了新程序的的环境列表。在调用execve
之后,因为同一进程依然存在,所以进程ID保持不变。
如果函数返回则表明发生错误,可以从errno
来判断原因。可能返回的错误有:EACCES
,代表pathname
指向的不是常规文件、文件不可执行或者其中某一级目录不可搜索;ENOENT
,代表pathname
指向的文件不存在;ENOEXEC
,代表系统无法识别文件格式;ETXTBSY
,代表存在进程以写入方式打开pathname
指代的文件;E2BIG
,代表参数列表和环境列表所需空间总和超出了允许的最大值。
基于execve
系统调用,还有下面的多种库函数可以选择,它们在为新程序指定程序名、参数列表以及环境变量的方式上有所不同:
1 |
|
这些函数的差异体现在函数名称在exec
之后的不同后缀:
- 后缀p代表系统会在由环境变量PATH所指定的目录列表中寻找相应的执行文件,允许只提供程序的文件名而不提供完整路径。如果文件名称中包含"/"则将其视为相对或者绝对路径名,不再使用变量PATH来搜索文件。
- 后缀l代表以字符串列表的形式来指定参数,而不使用数组来描述
argv
列表。字符串列表需要以NULL指针来终止。 - 后缀e代表允许手动为新程序指定环境变量,而其余函数则使用调用者当前环境作为新程序的环境
文件描述符与信号
默认情况下,由exec()
的调用程序所打开的所有文件描述符在exec()
的执行过程中会保持打开状态,且在新程序中依然有效。如果要改变这一设定,可以在打开文件描述符时设置FD_CLOEXEC
标志。
exec()
在执行时会将现有进程的文本段丢弃。该文本段可能包含了由调用进程创建的信号处理器程序。既然处理器已经不知所踪,内核就会将对所有已设信号的处置重置为SIG_DFL
,而对所有其他信号(即将处置置为SIG_IGN
或SIG_DFL
的信号)的处置则保持不变。
执行shell命令
程序可以通过调用system()
函数来执行任意的shell命令,用法如下:
1 |
|
函数会创建一个子进程来运行shell,并执行命令command
。它的使用十分简便,但是效率很低,因为在调用过程中需要创建至少两个进程,一个用于运行shell,另一个或者多个用于shell所执行的命令。
进程组和会话
概述
进程组由一个或多个共享同一进程组标识符(PGID)的进程组成。进程组ID是一个数字,其类型与进程ID一样(pid_t
)。一个进程组拥有一个进程组首进程,该进程是创建该组的进程,其进程ID为该进程组的ID,新进程会继承其父进程所属的进程组ID。进程组拥有一个生命周期,其开始时间为首进程创建组的时刻,结束时间为最后一个成员进程退出组的时刻。
会话是一组进程组的集合。进程的会话成员关系是由其会话标识符(SID)确定的,会话标识符与进程组ID一样,是一个类型为pid_t
的数字。会话首进程是创建该新会话的进程,其进程ID会成为会话 ID。新进程会继承其父进程的会话ID。
一个会话中的所有进程共享单个控制终端。控制终端会在会话首进程首次打开一个终端设备时被建立,而一个终端最多只会成为一个会话的控制终端。在任一时刻,会话中的其中一个进程组会成为终端的前台进程组,其他进程组会成为后台进程组。只有前台进程组中的进程才能从控制终端中读取输入。当用户在控制终端中输入其中一个信号生成终端字符之后,该信号会被发送到前台进程组中的所有成员。
进程组和会话是为支持shell作业控制而定义的抽象概念,用户通过shell能够交互式地在前台或后台运行命令。例如登录shell是会话首进程和终端的控制进程,也是其自身进程组的唯一成员;从shell发出的每个命令或者通过管道连接的一组命令都会导致一个或者多个进程的创建,并且shell会将其放入一个新的进程组中。当命令或者管道连接的一组命令以&符号结束时会在后台进程组中运行这些命令,否则会在前台进程组中运行这些命令。而在窗口环境中,每个终端窗口都有一个独立的会话,而窗口的启动shell就是会话首进程和终端的控制进程。
进程组
每个进程都拥有一个以数字表示的进程组ID,表示该进程所属的进程组。新进程会继承其父进程的进程组ID,要查看进程组ID可以使用下面的函数:
1 |
|
修改进程组ID可以使用下面的函数:
1 |
|
如果pid
的值为0,则调用进程的进程组ID就会被改变;如果pgid
设置为0,那么ID为pid
进程的进程组ID就会被设置为pid
的值。在其它情况下,如果pid
和pgid
参数指定同一个进程,那么就会创建一个新的进程组,且这个指定的进程为新组的首进程;如果两个参数指定不同的进程,那么会将一个进程从一个进程组移到另一个进程组。
控制终端保留了前台进程组的概念,前台进程组是唯一能够自由地读取和写入控制终端的进程组。在一个会话中,在同一时刻只有一个进程能成为前台进程,会话中的其他所有进程都是后台进程组。要获取或者修改一个终端的进程组可以使用下面的函数:
1 |
|
会话
一个进程的会话成员关系是由其会话ID来定义的,会话ID是一个数字。新进程会继承其父进程的会话ID。要查看会话ID可以使用getsid()
系统调用:
1 |
|
要创建一个新会话可以使用setsid()
系统调用:
1 |
|
调用进程成为新会话的首进程和该会话中新进程组的首进程,调用进程的进程组ID和会话ID会被设置成该进程的进程ID。调用进程没有控制终端,且所有之前到控制终端的连接都会被断开。如果调用进程是一个进程组首进程,那么setsid()
调用会报出EPERM错误,这一限制避免了破坏会话和进程组之间严格的两级层次。
一个会话中的所有进程可能会拥有一个控制终端。会话在被创建出来的时候是没有控制终端的,当会话首进程首次打开一个终端,且这个终端还没有成为某个会话的控制终端时,则会建立控制终端,除非在调用open()
时指定O_NOCTTY标记。而一个终端至多只能成为一个会话的控制终端。当会话首进程打开了一个控制终端之后它同时也成为了该终端的控制进程。在发生终端断开之后,内核会向控制进程发送一个SIGHUP信号来通知这一事件的发生。如果一个进程拥有一个控制终端,那么打开特殊文件/dev/tty
就能够获取该终端的文件描述符。
进程优先级和调度
进程优先级
Linux与大多数其他UNIX实现一样,调度进程使用CPU的默认模型是循环时间共享。这种模型中,每个进程轮流使用CPU一段时间,这段时间被称为时间片。循环时间共享方式满足了交互式多任务系统的公平性和响应度两个需求。在这种调度算法下,进程无法直接控制何时使用CPU以及使用CPU的时间。在默认情况下,每个进程轮流使用CPU 直至时间片被用光或自己自动放弃CPU。
进程特性nice值允许进程间接地影响内核的调度算法。每个进程都拥有一个nice值,其取值范围为−20(高优先级)~19(低优先级),默认值为0。需要注意的是,进程的调度不是严格按照nice值的层次进行的,nice值仅是一个权重因素,它导致内核调度器倾向于调度拥有高优先级的进程。给一个进程赋一个低优先级(即高nice值)并不会导致它完全无法用到CPU,但会导致它使用CPU的时间变少。nice值对进程调度的影响程度则依据Linux内核版本的不同而不同。
特权进程可以修改任意进程的优先级,而非特权进程只能修改自己的优先级,以及有效用户ID匹配进程的优先级。非特权进程可以降低或者在一定范围内提升nice值,而特权进程则可以任意修改nice值。
使用fork()
创建子进程时,会继承nice值,并且该值会在exec()
调用中得到保持。
要获取或者修改优先级可以使用下面的系统调用:
1 |
|
which
参数用于确定who
参数如何被解释,它的取值及其对who
的解释如下:
- PRIO_PROCESS:操作进程ID为
who
的进程,如果who
为0则使用调用者的进程ID。 - PRIO_PGRP:操作进程组ID为
who
的进程组中的所有成员,如果who
为0则使用调用者的进程组。 - PRIO_USER:操作所有真实用户ID为
who
的进程,如果who
为0则使用调用者的真实用户ID。
实时进程调度
Linux提供了另外两种实时调度策略:SCHED_RR和SCHED_FIFO,使用这两种策略中任意一种策略进行调度的进程的优先级,都要高于标准循环时间分享策略(SCHED_OTHER)来调度的进程。Linux 提供了 99 个实时优先级,其数值从1(最低)~99(最高),并且这个取值范围同时适用于两个实时调度策略。每个策略中的优先级是等价的,这意味着如果两个进程拥有同样的优先级,则两个都符合运行条件,运行哪个则取决于被调度的顺序。
在SCHED_RR(循环)策略中,优先级相同的进程以循环时间分享的方式执行。进程每次使用CPU的时间为一个固定长度的时间片。一旦被调度执行之后,使用SCHED_RR策略的进程会保持对CPU的控制,直到时间片被消耗完、自愿放弃CPU、程序终止、或是被优先级更改的进程抢占。对于前两种放弃控制的情况,进程将会被放在调度队列的队尾;而如果是被抢占,则会被放在队列的头部。这种调度方式会严格按照优先级的顺序进行调度。
SCHED_FIFO(先入先出)策略与SCHED_RR策略类似,它们之间最主要的差别在于在SCHED_FIFO策略中不存在时间片。一旦一个SCHED_FIFO进程获得了CPU的控制权之后,它就会一直执行到程序自愿放弃CPU、程序终止、或是被优先级更高的进程抢占。
要查看调度策略的优先级范围,可以使用下面的函数:
1 |
|
要修改一个进程的调度策略和优先级的方法如下:
1 |
|
通过fork()
创建的子进程会继承父进程的调度策略和优先级,并且在exec()
调用中会保持这些信息。
sched_setparam()
系统调用提供了sched_setscheduler()
函数的一个功能子集。它修改一个进程的调度策略,但不会修改其优先级:
1 |
|
要查看调度策略和优先级可以使用如下的系统调用:
1 |
|
实时进程如果要自愿释放CPU可以使用sched_yield
系统调用:
1 |
|
要查看SCHED_RR调度时间片的大小可以使用下面的系统调用:
1 |
|
CPU亲和力
当一个进程在一个多处理器系统上被重新调度时无需在上一次执行的CPU上运行。但是出于性能优化的考虑,有时需要为进程设置硬CPU亲和力,显式地将其限制在某一个或者一组CPU上运行。
要修改和获取进程的硬CPU亲和力可以使用下面的函数:
1 |
|
cpu_set_t
数据类型是一个位掩码,但是通常将其看作是不透明结构,对它的操作使用下面几个宏来完成:
1 |
|
进程资源
进程资源使用
要查看调用进程或其子进程用掉的各类系统资源的统计信息,可以使用getrusage()
系统调用:
1 |
|
其中,who
参数指定需要查询资源使用信息的进程,它的取值为下列的其中一个:
- RUSAGE_SELF:返回调用进程的相关信息
- RUSAGE_CHILDREN:返回调用进程的所有被终止和处于等待状态的子进程相关的信息。
- RUSAGE_THREAD:返回调用线程相关的信息
res_usage
参数是一个指向rusage
结构的指针,其中存储了各类系统资源使用的详细信息。
进程资源限制
每个进程都用一组资源限值,它们可以用来限制进程能够消耗的各种系统资源。要查看或者修改资源限制,可以使用下面的系统调用:
1 |
|
其中,resource
参数标识出了需读取或修改的资源限制,rlim
参数用来返回限制值或指定新的资源限制值。resource
参数可以使用的值包括:
DAEMON进程
概述
daemon进程通常指的是在后台运行并且不拥有控制终端,且生命周期很长的进程。例如httpd、inetd等进程。
创建流程
要创建一个daemon进程,程序要完成下面的步骤:
- 执行
fork()
,然后父进程退出,子进程继续执行。这保证子进程可以一直在后台执行,且子进程不会成为一个进程组的首进程 - 子进程调用
setsid()
,开启一个新会话,并释放它与控制终端的所有关联关系 - 如果daemon 后面可能会打开一个终端设备,那么必须要采取措施来确保这个设备不会成为控制终端。例如使用O_NOCTTY标记,或者在
setsid()
之后调用执行第二个fork()
并退出父进程 - 清除进程的
umask
,确保创建文件和目录时的所需权限 - 修改进程当前工作目录为根目录
- 关闭daemon从其父进程继承而来的所有打开着的文件描述符
- daemon 通常会打开/dev/null并使用
dup2()
(或类似的函数)使所有这些描述符指向这个设备
一些函数库提供了daemon()
函数,可以将调用者变为一个daemon进程。
重新初始化
有时需要修改daemon的操作参数,或者让其对文件进行处理,因此需要为daemon进程设置一些重新初始化的方法。一种方案是让daemon为SIGHUP信号建立一个处理器,并且在收到此信号时采取措施。由于daemon没有控制终端,因此内核永远不会向daemon发送SIGHUP信号。这样daemon就可以借助这个信号达到目的。
syslog工具
由于daemon是在后台运行的,因此通常无法像其他程序那样将消息输出到关联终端上。这个问题的一种解决方式是将消息写入到一个特定于应用程序的日志文件中。syslog 工具提供了一个集中式日志工具,系统中的所有应用程序都可以使用这个工具来记录日志消息。
syslog API 由以下三个主要函数构成:
openlog
:为后续的syslog
调用建立默认设置syslog
:记录一条日志消息closelog
:完成日志消息记录之后,拆除与日志之间的连接
它们的使用方法为:
1 |
|
/etc/syslog.conf
配置文件控制syslogd daemon的操作,这个文件由规则和注释构成。通过这一文件,可以实现一些更加强大的规则,例如指定消息发送的位置、指定发送消息的类型等。
线程
概述
线程是允许应用程序并发执行多个人物的一种机制,一个进程可以包含多个线程。同一程序中的所有线程会独立执行相同区域,且共享同一份全局内存区域。因此,线程之间可以方便、快速地共享信息,同时创建线程的速度也比创建进程要快得多。
线程之间共享的属性包括:全局内存、进程ID和父进程ID、进程组ID和会话ID、控制终端、进程凭证、打开的文件描述符、记录锁、信号处置、文件系统的相关信息、间隔定时器和POSIX定时器、System V信号量撤销值、资源限制、CPU时间消耗、资源消耗、nice值
各线程独有的属性包括:线程ID、信号掩码、线程特有数据、备选信号栈、errno变量、浮点型环境、实时调度策略和优先级、CPU亲和力、Linux特有的Capability、栈,本地变量和函数的调用链接信息。
创建与终止
启动程序时,产生的进程只有单条线程,被称为初始或者主线程。函数pthread_create()
负责创建一条新的线程:
1 |
|
新线程通过调用带有参数arg
的函数start
,即start(arg)
来开始执行。而调用pthread_create
的线程则会接着继续执行该调用之后的程序语句。参数thread
指向一个pthread_t
类型的缓冲区,在pthread_create()
返回之前,会在此保持一个该线程的唯一标识,后续的Pthreads
函数将会使用该标识来引用此线程。参数attr
是一个指向pthread_attr_t
对象的指针,该对象指定了新线程的各种属性,如果将其设置为NULL,则创建新线程时将会使用各种默认属性。
如果要终止线程,有如下几种方法:
- 线程start函数执行return语句并返回指定值
- 线程调用
pthread_exit()
- 调用
pthread_cancel()
取消线程 - 任意线程调用了
exit()
,或者主线程执行了return语句,此时会导致进程中的所有线程立即终止。
pthread_exit()
函数将终止调用线程,且其返回值可以由另一个线程通过调用pthread_join()
来获取,使用方法如下:
1 |
|
执行这一函数相当于在线程的start
函数中执行return,但是这一函数可以在线程start
函数所调用的任意函数中被调用。参数retval
指定了线程的返回值,它所指向的内容不应被分配到线程栈中,因为线程终止之后无法确定线程栈中的内容是否有效。
线程的连接与分离
默认情况下,线程是可连接的,也就是说当其退出时,其它线程可以获取其返回状态。函数pthread_join()
等待由thread
标识的线程终止,如果线程已经终止则立刻返回,用法如下:
1 |
|
如果retval
是非空指针,将会保存线程终止时返回值的拷贝,即线程调用return
或者pthread_exit()
时传入的值。
如果线程没有被分离,则必须使用pthread_join
来进行连接,否则在线程终止时将会产生一个僵尸线程。
需要注意的是,一个进程的任意线程都可以调用pthread_join
与该进程的任何其它线程连接起来,即线程之间的关系对等;这与进程间的层次关系不同,进程只能由父进程对子进程调用wait
。但是可以连接不代表任意线程的连接能够成功,可以限制只能连接特定的线程ID,且线程连接也不能以非阻塞方式进行。
有时,我们并不关心线程的返回状态,只希望系统在线程终止时可以自动清理并移除,此时可以使用pthread_detach()
系统调用:
1 |
|
这一系统调用传入thread
指定要分离的线程标识符,调用成功之后线程便会处于分离状态,在此之后不能再使用pthread_join()
来获取其状态,也无法使其重返可连接的状态。
线程同步
互斥量
互斥量用于确保同时仅有一个线程可以访问某一项共享资源,它可以保证访问操作的原子性。互斥量只有两种状态:已锁定和未锁定。任何时候,至多只有一个线程可以锁定该互斥量;而一旦线程锁定互斥量,则成为该互斥量的所有者,只有该线程才可以给互斥量解锁。
一般情况下,对每一个共享资源会使用不同的互斥量,而每一个线程在访问同一资源时将采用如下步骤:针对共享资源锁定互斥量、访问共享资源、对互斥量解锁。
创建一个互斥量的方法分为静态和动态两种。静态分配一个互斥量的方法如下:
1 | pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; |
在静态初始化一个互斥量之后,互斥量处于未锁定状态。
而动态分配与销毁一个互斥量的方法为:
1 |
|
在pthread_mutex_init
函数中,参数mutex
指定函数执行初始化操作的目标互斥量,参数attr
是指向pthread_mutexattr_t
类型对象的指针,该对象在函数调用之前已经被初始化处理,用于定义互斥量的属性。如果这一参数被设为NULL,那么互斥量的各种属性将会取默认值。而当动态分配的互斥量mutex
不需要再被使用之后,便可以使用pthread_mutex_destroy
函数将其销毁。
使用下面两个函数可以锁定或者解锁某个互斥量:
1 |
|
在调用pthread_mutex_lock
时,需要指定互斥量,如果互斥量当前处于未锁定状态,则会锁定互斥量并立即返回;如果其它线程已锁定了这个互斥量,那么这一调用将会一直堵塞,直到该互斥量被解锁,而此时将会锁定互斥量并返回。如果一个线程在调用pthread_mutex_lock
时,已经将目标的互斥量锁定,则线程会陷入死锁状态。
函数pthread_mutex_unlock
将会解锁之前已经被锁定的互斥量。如果对处于未锁定状态的互斥量进行解锁,或者是解锁由其它线程锁定的互斥量都会返回错误。
有时,一个线程需要同时访问两个或更多不同的共享资源,而每个资源又都由不同的互斥量管理。当超过一个线程加锁同一组互斥量时,就有可能发生死锁。要避免此类死锁问题,最简单的方法是定义互斥量的层级关系。当多个线程对一组互斥量操作时,总是应该以相同顺序对该组互斥量进行锁定。
pthread_mutexattr_t
类型的变量可为互斥量设置不同的属性。使用pthread_mutexattr_init(pthread_mutexattr_t* attr)
函数可以初始化一个pthread_mutexattr_t
类型的变量;pthread_mutexattr_settype(pthread_mutexattr_t* attr, int flags)
函数可以添加属性;当pthread_mutexattr_t
类型的变量不需要再使用时,可以使用函数pthread_mutexattr_destroy(pthread_mutexattr_t& attr)
将其释放掉。
条件变量
条件变量允许一个线程就某个共享变量(或其他共享资源)的状态变化通知其他线程,并让其他线程等待(堵塞于)这一通知。条件变量总是结合互斥量使用。条件变量就共享变量的状态改变发出通知,而互斥量则提供对该共享变量访问的互斥(mutual exclusion)。
同互斥量一样,条件变量的分配有静态和动态之分。静态创建的方法为:
1 | pthread_cond_t cond = PTHREAD_COND_INITIALIZER; |
动态创建与销毁环境变量的函数如下:
1 |
|
条件变量的主要操作是发送信号和等待。发送信号操作即通知一个或多个处于等待状态的线程,某个共享变量的状态已经改变。等待操作是指在收到一个通知前一直处于阻塞状态。相关的函数包括:
1 |
|
每个条件变量都有与之相关的判断条件,涉及一个或者多个共享变量。由于代码从pthread_cond_wait
返回时,并不能确定判断条件的状态,因此应该重新检查判断条件,在条件不满足的情况下继续等待。所以必须使用while循环而不是if语句来控制对pthread_cond_wait()
的调用。
线程安全
若函数可同时供多个线程安全调用,则称之为线程安全函数;反之,如果函数不是线程安全的,则不能并发调用。实现线程安全有多种方式,一是将函数与互斥量关联使用,二是将共享变量与互斥量关联起来。因此,在多线程的程序中如果要用到一些系统调用或者库函数,需要确定它们的线程安全性。
实现函数线程安全最为有效的方式就是使其可重入,应以这种方式来实现所有新的函数库。而对于已有的函数而言,使用线程特有数据技术,可以无需修改函数接口而实现已有函数的线程安全。
要使用线程特有数据的一般步骤如下:
- 函数创建一个键(key),用来将不同函数使用的线程特有数据项区分开来。可以使用调用函数
pthread_key_create()
函数来创建,这一调用也允许调用者自定义一个析构函数,用于释放为该键分配的存储块 - 函数为每个调用者线程创建线程特有的数据块
- 函数使用
pthread_setspecific()
和pthread_getspecific()
来存储或者提取数据
要创建一个新键可以使用pthread_key_create()
函数:
1 |
|
只要线程终止时与key
的关联值不为NULL
,Pthreads API 会自动执行解构函数,并将与key
的关联值作为参数传入解构函数。传入的值通常是与该键关联,且指向线程特有数据块的指针。如果无需解构,那么可将destructor
设置为NULL
。
而存储与取出数据的函数如下:
1 |
|
一种更简单的方法是使用线程局部存储。如果要创建线程局部变量,只需要简单地在全局或者静态变量的声明中包含__thread
说明符即可:
1 | static __thread buf[10] |
带有这一说明符的变量,每个线程都拥有一份对变量的拷贝。线程局部存储中的变量将会一直存在直到线程终止。
线程取消
有时候,需要向线程发送请求让它立即退出,例如一个图形用户界面的应用程序的取消按钮就对应于终止后台某一线程正在执行的任务。在这种情况下,主线程(即控制图形用户界面)需要请求后台线程退出。
函数pthread_cancel()
向一个指定线程发送取消请求:
1 |
|
发送取消请求之后,函数立即返回,不会等待目标线程的退出。目标线程对这一指令的响应过程可以使用下面两个函数进行控制:
1 |
|
其中,state
参数可以设置为PTHREAD_CANCEL_DISABLE
或者PTHREAD_CANCEL_ENABLE
,分别对应于线程不可取消和线程可以取消。oldstate
用于保存前一个状态。
参数type
可以设置为PTHREAD_CANCEL_ASYNCHRONOUS
,代表可能会在任何时刻取消线程,因此一般原则是可异步取消的线程不应该分配任何资源,也不能获取互斥量或锁;或者PTHREAD_CANCEL_DEFERED
,代表取消请求保持挂起状态直到到达取消点(有一系列的取消点函数,可以查阅相关资料),这也是新建线程的默认类型。参数oldtype
保存之前的状态。
如果线程执行的是一个不包含取消点的循环,则永远不会响应取消请求。如果要手动加入取消点,则可以使用下面的函数:
1 |
|
如果一个线程已有处于挂起状态的取消请求,那么只要调用该函数,则线程会立即终止。
在线程执行到取消点时,如果仅仅是直接退出,则很可能会导致一些共享变量或者Pthreads对象处于不一致的状态,导致进程中的其它线程产生错误结果、死锁等。因此,线程可以设置清理函数,当线程被取消时会自动执行这些函数,用法如下:
1 |
|
线程实现细节
线程ID
进程内部的每一个线程都有一个唯一的线程ID作为标识。线程ID会返回给pthread_create()
的调用者,一个线程可以使用pthread_self()
函数来获取自己的线程ID:
1 |
|
而pthread_equal()
可以检查两个线程的ID是否相同:
1 |
|
线程栈
创建线程时,每个线程都有一个属于自己的线程栈,且大小固定。主线程的线程栈要大一些,除此之外的所有线程栈大小都相等。使用函数pthread_attr_setstacksize()
可以设置线程栈的大小。
线程和信号
在UNIX信号模型中,一些方面属于进程层面(即进程中的所有线程共享),另一些方面属于线程层面。一些关键规则包括:
- 信号动作属于进程层面。如果某进程的任一线程收到任何未经(特殊)处理的信号,且其缺省动作为stop 或terminate,那么将停止或者终止该进程的所有线程。
- 对信号的处置属于进程层面,进程中的所有线程共享对每个信号的处置设置
- 信号的发送既可针对整个进程,也可针对某个特定线程。如果信号产生源于线程上下文的特定硬件指令执行、线程试图对断开的管道进行写操作、或者由函数
pthread_kill()
或pthread_sigqueue()
所发出的信号,那么这些信号是面向线程的,除此之外的信号是面向进程的。 - 当多线程程序收到一个信号,且该进程已然为此信号创建了信号处理程序时,内核会任选一条线程来接收这一信号,并在该线程中调用信号处理程序对其进行处理。
- 信号掩码(mask)是针对每个线程而言,每个线程可以设置自己的信号掩码
- 针对为整个进程所挂起(pending)的信号,以及为每条线程所挂起的信号,内核都分别维护有记录
刚创建的新线程会从其创建者处继承信号掩码的一份拷贝。线程可以使用pthread_sigmask()
来改变或/并获取当前的信号掩码:
1 |
|
它的用法与sigprocmask
完全相同。
如果要向一个线程发送信号,可以使用pthread_kill()
函数:
1 |
|
Linux特有的函数pthread_sigqueue()
将pthread_kill()
和sigqueue()
的功能合并,可以向同一进程的另一线程发送携带数据的信号:
1 |
|
由于没有任何Pthreads API属于异步信号安全函数,因此当多线程应用程序处理异步产生的信号时,通常不应该将信号处理函数作为接收信号到达的通知机制。推荐的方法是所有线程都阻塞进程可能接收的所有异步信号,然后再创建一个专用线程来接收信号,从而实现同步接收异步产生的信号。
线程和进程控制
对于exec()
系列函数,只要有任意一个线程调用它,则调用程序将被完全替换。除了调用exec
的线程之外,其余线程立刻消失。没有任何线程会针对线程特有数据执行解构函数(destructor),也不会调用清理函数(cleanup handler)。该进程的所有互斥量(为进程私有)和属于进程的条件变量都会消失。调用exec()
之后,调用线程的线程ID 是不确定的。
当多线程进程调用fork()
时,仅会将发起调用的线程复制到子进程中。(子进程中该线程的线程ID与父进程中发起fork()
调用线程的线程ID相一致。)其他线程均在子进程中消失,也不会为这些线程调用清理函数以及针对线程特有数据的解构函数。这将会导致全局变量的状态以及所有的Pthreads对象都会在子进程中得以保留,可能会导致线程阻塞或者内存泄漏。因此,推荐在多线程程序中的fork()
调用后紧跟exec()
调用;或者使用pthread_atfork()
系统调用来创建fork处理函数。
如果任何线程调用了exit()
,或者主线程执行了return,那么所有线程都将消失,也不会执行线程特有数据的解构函数以及清理函数。
Linux的线程实现
目前Linux的线程实现为NPTL,它属于一对一实现的线程模型,即内核分别对每个线程做调度处理。线程同步操作通过内核系统调用实现。Linux的线程使用函数clone()
创建,并指定如下标志:CLONE_VM|CLONE_FILES|CLONE_FS|CLONE_SIGHAND|CLONE_THREAD|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID|CLONE_SYSVSEM。
系统调用clone
的用法如下:
1 |
|
clone
生成的子进程在继续运行时会调用func
参数指定的函数,它的参数由func_arg
指定。flags
参数存放位掩码,用于控制clone
的操作。
在Linux系统中,实际上线程和进程都是内核调度实体(Kernel Scheduling Entity, KSE),只是与其他KSE之间对属性(虚拟内存、打开文件描述符、对信号的处置、进程ID等)的共享程度不同。
信号
概述
信号产生
信号是事件发生时对进程的通知机制,有时也被称为软件中断。一个具有合适权限的进程可以向另一个进程发送信号,这一用法可以作为一种同步技术,或是进程间通信的方式。但是将信号利用于通信的场景很少,因为标准信号不能排队处理,实时信号也存在对信号排队数量的限制,而且信号可携带的信息量也有限。
发往进程的信号通常都源于内核,引发内核为进程产生信号的各类事件包括:
- 硬件发生异常,即硬件检测到一个错误条件并通知内核,然后再由内核发送相应信号给相关进程。硬件异常的例子包括执行一条异常的机器语言指令、引用无法访问的内存区域等。
- 用户键入能够产生信号的终端特殊字符,包括中断字符、暂停字符等。
- 发生了软件事件。例如针对于文件描述符的输出变为有效、定时器到期、进程执行的CPU事件超限、进程的某个子进程退出等。
信号的生成分为两种方式:同步生成和异步生成。异步生成指的是引发信号产生的事件与进程的执行无关,例如子进程终止、输入中断字符等。对于这类信号,进程一般无法预测其接收信号的时间。而另一种为同步生成,指的是进程本身的执行产生信号,例如执行特定的机器语言指令导致了硬件异常,或者是进程使用raise()
、kill()
或者killpg()
向自身发送信号。
针对于每个信号,都定义了一个唯一的小整数,从1开始对它们进行标记。在头文件<signal.h>
中,以SIGxxxx
形式的符号名对这些整数做了定义。在Linux中,编号1-31所对应的信号为标准信号,用于内核向进程通知事件;而其余编号表示实时信号。
信号的传递与响应
同步产生的信号会立即传递,例如硬件异常会触发一个即时信号;而当进程使用raise()
向自身发送信号时,信号也会在raise()
调用返回之前就已经发出。
而对于异步信号来说,它们在产生之后,可能会在稍后被传递给某一个进程,中间可能会存在一个瞬时延迟。在产生和到达期间,信号处于等待状态。这是因为内核将等待信号传递给进程的时机为,该进程正在执行,且发生由内核态到用户态的下一次切换时。这意味着只有在系统调用完成时,或者进程再度获得调度时(即一个时间片的开始处),才会发生信号的传递。
如果要确保一段代码不被传递来的信号所中断,可以将信号添加到进程的信号掩码中,这样便会阻塞信号的到达。如果所产生的信号属于阻塞之列,则信号将保持等待状态直到稍后对其解除阻塞。如果一个进程同时解除对多个等待信号的阻塞,那么所有这些信号都会立即传递给该进程。Linux内核会按照信号编号的升序来传递信号。
信号到达之后,进程将视具体信号执行如下的默认操作之一:
- 忽略信号:内核将信号丢弃,信号对进程没有产生任何影响(相当于进程永远不知道曾经出现过该信号)
- 终止进程:进程异常终止
- 产生核心转储文件,同时进程终止:核心转储文件包含了进程虚拟内存的镜像,可将其加载到调试器中,以检查进程终止时的状态
- 停止进程:暂停进程的执行
- 恢复进程:在之前暂停之后,再次恢复进程的执行
备注—核心转储文件的概念:
特定信号会引发进程创建一个核心转储文件并终止运行,核心转储指的是内含进程终止时内存映像的一个文件。将这一内存映像加载到调试器中,即可查明信号到达时程序代码和数据的状态。
例如在程序运行时键入退出字符(Ctrl+),则会生成SIGQUIT信号,此时shell会显示core dump信息,代表生成了核心转储文件。这一文件创建于进程的工作目录中,名为core。
除了根据特定信号产生上述的默认行为,程序也可以改变信号到达时的响应行为,也将其称为对信号的处置设置。程序可将信号的处置设为如下之一:
- 采取默认行为:即撤销之前对于信号处置的修改,恢复其默认处置
- 忽略信号:适用于默认行为是终止进程的信号
- 执行信号处理器程序:这一程序是程序员编写的函数,用于执行适当任务以响应传递来的信号
信号类型
下表所示为Linux系统与信号相关的信息:
其中,信号值在不同的硬件架构下也具有不同的编号。默认列显示的是信号的默认行为,term表示信号终止进程,core表示进程产生核心转储文件并退出,ignore表示忽略该信号,stop表示信号停止了进程,cont表示信号恢复了一个已停止的进程。
信号集
许多信号相关的系统调用都需要能够表示一组不同的信号。多个信号可以使用一个称为信号集的数据结构来表示,其系统数据类型为sigset_t
。可以用来操纵信号集的函数有:
1 |
|
信号掩码
内核会为每个进程维护一个信号掩码,即一组信号,并将阻塞其针对该进程的传递。如果将遭阻塞的信号发送给某进程,那么对该信号的传递将延后,直至从进程信号掩码中移除该信号,从而解除阻塞为止。
向信号掩码中添加信号的方式有:
- 调用信号处理器程序时,可将引发调用的信号自动添加到信号掩码中,是否发生这一情况要视
sigaction()
函数在安装信号处理器程序时使用的标志而定。 - 使用
sigaction()
函数建立信号处理器程序时,可以指定一组额外信号,当调用该处理器程序时会将其阻塞。 - 使用
sigprocmask()
系统调用,随时显式地向信号掩码中添加或者移除信号。
sigprocmask()
的使用方法如下:
1 |
|
这一函数既可以修改进程的信号掩码,也可以获得现有的掩码。其中,how
参数指定了函数想给信号掩码带来的变化,它的值可以为:
- SIG_BLOCK:将
set
指向信号集内的指定信号添加到信号掩码中 - SIG_UNBLOCK:将
set
指向信号集内的指定信号从信号掩码中移除 - SIG_SETMASK:将
set
指向的信号集赋给信号掩码
如果oldset
参数不为空,则它应该指向一个sigset_t
结构的缓冲区,用于返回之前的信号掩码。如果要获取信号掩码但是对其不做改动,则可以将set
参数设为空值,此时将忽略how
参数。
特殊信号
一些特定的信号在传递、处置和处理方面适用于一些特殊规则:
- SIGKILL和SIGSTOP:SIGKILL的默认行为是终止一个进程,SIGSTOP信号的默认行为是停止一个进程,二者的默认行为均无法改变。同样,这两个信号也不能被阻塞。
- SIGCONT和停止信号:如果一个进程处于停止状态,那么一个SIGCONT 信号的到来总是会促使其恢复运行,即使该进程正在阻塞或者忽略SIGCONT 信号。因为只有这种方法可以恢复一个处于停止状态的进程。每当进程收到SIGCONT 信号时,会将处于等待状态的停止信号丢弃。相反,如果任何停止信号传递给了进程,那么进程将自动丢弃任何处于等待状态的SIGCONT 信号。
- 如果程序在执行时发现,已将对由终端产生信号的处置置为了SIG_IGN(忽略),那么程序通常不应试图去改变信号处置。与之相关的信号有:SIGHUP、SIGINT、SIGQUIT、SIGTTIN、SIGTTOU 和SIGTSTP。
而系统中的硬件异常也可以产生SIGBUS、SIGFPE、SIGILL,和SIGSEGV信号,在硬件异常的情况下,如果进程从此类信号的处理器函数中返回,或者是进程忽略或阻塞了这类信号,那么进程的行为未定义。正确处理硬件产生信号的方法有两种,要么接受信号的默认行为(进程终止),要么为其编写不会正常返回的处理器函数。
改变信号处置
API
UNIX系统提供了两种方法来改变信号处置:signal()
和sigaction()
。signal()
的接口相对简单,但是它的行为在不同的UNIX实现之间存在差异。因此,建立信号处理器应该优先考虑使用sigaction()
函数。
signal
的用法如下:
1 |
|
其中,第一个参数sig
标识希望修改处置的信号编号;第二个参数handler
则标识信号抵达时所调用函数的地址。该函数无返回值,并接受一个整型参数。handler
所对应的信号处理器函数一般具有如下格式:
1 | void handler(int sig) |
如果调用signal
成功,则返回之前的信号处置函数,它是一枚指针,指向带有一个整型参数且无返回值的函数。
handler
参数也可以用如下值来代替函数地址:
SIG_DFL
:将信号处置重置为默认值SIG_IGN
:忽略该信号
sigaction
的用法比signal
要更灵活一些。sigaction()
允许在获取信号处置的同时无需将其改变,并且,还可设置各种属性对调用信号处理器程序时的行为施以更加精准的控制。此外,在建立信号处理器程序时,sigaction()
较之signal()
函数可移植性更佳。它的用法如下:
1 |
|
sig
参数标识想要获取或改变的信号编号。该参数可以是除去SIGKILL和SIGSTOP之外的任何信号。act
参数是一枚指针,指向描述信号新处置的数据结构。如果仅对信号的现有处置感兴趣,那么可将该参数指定为NULL
。oldact
参数是指向同一结构类型的指针,用来返回之前信号处置的相关信息。如果无意获取此类信息,那么可将该参数指定为NULL
。
sigaction
结构类型如下所示:
1 | struct sigaction{ |
sa_mask
字段定义了一组信号,在调用由sa_handler
所定义的处理器程序时将阻塞该组信号。当调用信号处理器程序时,会在调用信号处理器之前,将该组信号中当前未处于进程掩码之列的任何信号自动添加到进程掩码中。这些信号将保留在进程掩码中,直至信号处理器函数返回,届时将自动删除这些信号。此外,引发对处理器程序调用的信号将自动添加到进程信号掩码中,保证不会递归地中断自己。
而sa_flags
字段是一个位掩码,指定用于控制信号处理过程中的各种选项。
信号处理器函数
设计原则
信号处理器程序(也称为信号捕捉器)是当指定信号传递给进程时将会调用的一个函数。调用信号处理器程序,可能会随时打断主程序流程;内核代表进程来调用处理器程序,当处理器返回时,主程序会在处理器打断的位置恢复执行。
一般而言,信号处理器函数设计地越简单越好,这将降低引发竞争条件的风险。两种常见的设计方式为:
- 信号处理器函数设置全局性标志变量并退出。主程序对此标志进行周期性检查,一旦置位便采取相应动作
- 信号处理器函数执行某种类型的清理动作,接着终止进程或者使用非本地跳转,将栈解开并将控制返回到主程序的预定位置
在信号处理器函数中,并非所有的系统调用和库函数都可以安全调用。在编写信号处理器函数时有两种选择:
- 确保信号处理器函数代码本身可重入,且只调用异步信号安全的函数
- 当主程序执行不安全函数,或者操作信号处理器函数也可以更新的全局数据结构时,阻塞信号的传递(这一要求有些困难,)
备注—可重入与异步信号安全的概念
可重入指的是,函数由两条或多条线程调用时,即便是交叉执行,其效果也与各线程以未定义顺序依次调用时一致。例如更新全局变量或静态数据结构的函数可能是不可重入的。
如果某一函数是可重入的,又或者信号处理器函数无法将其中断时,就称该函数是异步信号安全的。
而如果必须要共享某些全局变量,则可以在声明变量的时候使用volatile
关键字,并且使用sig_atomic_t
来保证读写操作的原子性。也就是说,所有在主程序和信号处理器函数之间共享的全局变量应声明为:
1 | volatile sig_atomic_t flag |
终止信号处理器函数
信号处理器函数的终止方式包括:
- 返回主程序
- 使用
_exit()
终止进程,处理器函数可以提前做一些清理工作 - 使用
kill()
发送信号来杀掉进程 - 从信号处理器函数中执行非本地跳转
- 使用
abort()
函数终止进程,并产生核心转储
如果使用longjmp()
来退出信号处理器函数,这一系统调用是否会恢复信号掩码取决于具体的UNIX实现。因此,最好是使用如下一对系统调用:
1 |
|
在sigsetjmp
函数中多出了一个参数savesigs
,如果它被设置为非0值,那么sigsetjmp
会将进程的当前掩码保存在env
中,之后通过相同env
参数的siglongjmp
调用进行恢复;如果它被设置为0,则不会保存和恢复进程的信号掩码。
需要注意的是,这两个函数都不是异步信号安全的。
abort()
的使用方法如下:
1 |
|
函数abort()
通过产生SIGABRT
信号来终止调用进程,对这一信号的默认动作是产生核心转储文件并终止进程。无论阻塞或者忽略SIGABRT
信号,abort()
调用均不受影响;而且除非进程捕获SIGABRT
信号之后信号处理器函数尚未返回,否则abort()
必须终止进程。
SA_SIGINFO标志
如果在使用sigaction()
创建处理器函数的时候,设置了SA_SIGINFO
标志,那么在收到信号时,处理器函数可以获取该信号的一些附加信息。为了获取这一信息,需要将处理器函数声明如下:
1 | void handler(int sig, siginfo_t* siginfo, void* ucontext); |
其中sig
表示信号编号,siginfo
是用于提供信号附加信息的一个数据结构,ucontext
则是一个指向ucontext_t
类型数据结构的指针,该结构提供了用户上下文信息,用于描述调用信号处理器函数之前的进程状态。
系统调用的中断和重启
信号处理器函数返回之后,默认情况下,系统调用失败,并将errno
设置为EINTR。如果希望遭到中断的系统调用可以继续运行,则可以在sigaction
调用中设置SA_RESTART
标志。
需要注意的是,并非所有的系统调用都可以通过指定SA_RESTART
来达到自动重启的目的。
信号相关操作
发送信号
一个进程可以使用kill()
系统调用向另一个进程发送信号(之所以用kill,是因为早期的UNIX实现中,大多数信号的默认行为是终止进程)。它的用法如下:
1 |
|
其中sig
代表要发送的信号。pid
参数用于标识一个或者多个目标进程,如何解释这一参数要视其具体数值:
- 如果
pid
大于0,则发送信号给pid
指定的进程 - 如果
pid
等于0,则发送信号给与调用进程同组的每个进程,包括调用进程自身 - 如果
pid
小于-1,那么会向组ID等于该pid
绝对值的进程组内所有下属进程发送信号 - 如果
pid
等于-1,那么除去init和调用进程自身之外,给它有权将信号发往的所有进程发送信号。这一方式也称为广播信号
如果没有进程与指定的pid
匹配,则kill
调用失败,同时将errno
设置为ESRCH(即查无此进程)。
进程要发送信号给另一个进程,还需要适当权限,权限的规则如下:
- 特权级进程可以向任何进程发送信号
- 以root用户和组运行的init进程(进程号为1)仅接收已安装了处理器函数的信号,这可以防止系统管理员意外杀死init进程
SIGCONT
信号需要特殊处理。无论对用户ID的检查结果如何,非特权进程可以向同一会话中的任何其他进程发送这一信号- 如果发送者的实际或有效用户ID匹配于接受者的实际用户ID或者保存设置用户ID(saved set-user-id),那么非特权进程也可以向另一进程发送信号
如果进程无权发送信号给所请求的pid
,那么kill()
调用将失败,且将errno
置为EPERM。若pid
所指为一系列进程(即pid
是负值)时,只要可以向其中之一发送信号,则kill()
调用成功。
kill()
函数的另一种用法是,如果将参数sig
设置为0(即空信号),则无信号发送。但是此时仍然会执行错误检查,查看是否可以向目标进程发送信号。利用这一特点,可以使用空信号来检测具有特定ID的进程是否存在。如果发送空信号失败,且errno
是EPERM,也或者是调用成功,则表示进程存在。
此外,还有一些其它的发送信号方式。
raise()
函数用于给自身发送信号,用法如下:
1 |
|
killpg()
函数向某一进程组的所有成员发送一个信号,用法如下:
1 |
|
显示信号描述
每个信号都有一串与之相关的可打印说明,这些描述位于数组sys_siglist
中。例如可以直接使用sys_siglist[SIGPIPE]
来获取对SIGPIPE
信号的描述。另一种办法是使用strsignal
函数:
1 |
|
strsignal
函数对sig
参数进行边界检查,然后返回一枚指针,指向针对于该信号的可打印描述字符串,或者是当信号编号无效时指向错误字符串。
psignal
函数所示为msg
参数所给定的字符串,后面跟有一个冒号,随后是对应于sig
的信号描述。
处于等待状态的信号
如果某进程接受了一个该进程正在阻塞的信号,那么会将该信号填加到进程的等待信号集。当(且如果)之后解除了对该信号的锁定时,会随之将信号传递给此进程。sigpending()
系统调用可以确定进程中处于等待状态的信号:
1 |
|
这一调用为调用进程返回处于等待状态的信号集,并将其置于set
指向的sigset_t
结构中。而等待信号集仅仅是一个掩码,仅表明信号是否发生,而未表明其发生的次数。如果同一个信号在阻塞状态下发生多次,那么会将该信号记录在等待信号集中,并在随后只传递一次。
等待信号
调用pause
将暂停进程执行,直到信号处理器函数中断该调用,或者一个未处理信号终止进程。也就是说,只有当前进程接收到信号之后,进程才可能会继续执行下去,否则会一直等待信号的到来。
1 |
|
处理信号时,pause()
会遭到中断并返回。
在对信号编程时偶尔会遇到如下的情况,需要临时阻塞一个信号,以防止其信号处理器不会将某些关键代码片段的执行中断,然后解除对这一信号的阻塞并暂停执行,直到有信号到达。而解除并暂停执行这一步操作需要保证其原子性,否则可能会出现竞争条件。因此,可以使用sigsuspend
系统调用:
1 |
|
这一系统调用将mask
所指向的信号集来替换进程的信号掩码,然后挂起进程的指向,直到其捕获到信号,并从信号处理器中返回。一旦处理器返回,进程的信号掩码将被恢复为调用前的值。
若sigsuspend()
因信号的传递而中断,则将返回−1,并将errno
置为EINTR。如果mask
指向的地址无效,则sigsuspend()
调用失败,并将errno
置为EFAULT。
另一个替代方案是使用sigwaitinfo()
系统调用,可以用来同步接收信号:
1 |
|
这一系统调用会挂起进程的执行,直到set
所对应信号集中的某一信号抵达。如果在调用时,set
中的某一信号已经处于等待状态,那么函数会立即返回。传递来的信号就会从进程的等待信号队列中移除,并将返回信号编号作为函数结果。
info
参数如果不为空,则会指向经初始化处理的siginfo_t
结构,其中包含的信息与提供给信号处理器函数的这一参数相同。
它的一个变体是sigtimedwait()
系统调用,这一函数允许指定等待时限:
1 |
|
其中timeout
参数指向一个timespec
数据结构,是指向如下数据结构的一枚指针:
1 | struct timespec{ |
通过文件描述符获取信号
Linux提供了一个非标准的signalfd()
系统调用,利用它可以创建一个特殊的文件描述符,发往调用者的信号都可以从该描述符中读取。用法如下:
1 |
|
其中,mask
参数是一个信号集,指定了有意通过signalfd
文件描述符来读取的信号;fd
参数如果为-1,则创建一个新的文件描述符,否则会修改与fd
相关的mask
值,且要求这一fd
一定是由之前signalfd()
的一次调用创建而得。flags
参数可以设置为SFD_CLOEXEC和SFD_NONBLOCK
。
创建了文件描述符之后,便可以使用read()
从中读取信号。提供给read
的缓冲区必须足够大,至少能够容纳一个signalfd_siginfo
结构。
实时信号
概述
实时信号用于弥补对于标准信号的限制,相比于标准信号,它具有如下这些优势:
- 信号范围有所扩大,可用于应用程序自定义的目的。
- 对实时信号采取队列化管理。如果将某个实时信号的多个实例发送给一个进程,则会多次传递该信号
- 当发送一个实时信号时,可为信号指定伴随数据。
- 不同实时信号的传递顺序具有保障,信号的优先级与编号有关,编号越小则优先级越高
在<signal.h>
头文件中定义的RTSIG_MAX常量表征实时信号的可用数量,常量SIGRTMIN和SIGRTMAX则分别表示可用实时信号编号的最小值和最大值。
发送实时信号
系统调用sigqueue()
将sig
指定的实时信号发送给由pid
指定的进程。用法如下:
1 |
|
使用sigqueue
发送信号的权限与kill
的要求一致,也可以发送空信号(即信号0)。但是sigqueue
不能通过将pid
设置为负值而向整个进程组发送信号。参数value
是一个sigval
类型的联合体,指定了信号的伴随数据,具有以下形式:
1 | union sigval{ |
一旦触及到排队信号的数量限制,sigqueue
调用将会失败,同时将errno
设置为EAGAIN,表示需要再次发送信号。
处理实时信号
实时信号的处理方式与标准信号一样,可以使用signal()
或者sigaction()
函数来处理实时信号。
进程间通信
UNIX 系统上各种通信和同步工具可以根据功能分成三类:
- 通信:这些工具关注进程之间的数据交换。
- 同步:这些进程关注进程和线程操作之间的同步。
- 信号:尽管信号的主要作用并不在此,但在特定场景下仍然可以将它作为一种同步技术。更罕见的是信号还可以作为一种通信技术:信号编号本身是一种形式的信息,并且可以在实时信号上绑定数据(一个整数或指针)。
这些工具的分类如下图所示:
通常使用通用术语进程间通信(IPC)指代所有这些工具。
管道和FIFO
概述
管道可以用来在相关进程之间传递数据。FIFO 是管道概念的一个变体,它们之间的一个重要差别在于FIFO 可以用于任意进程间的通信。一个管道有如下几个特征:
- 一个管道是一个字节流。从管道中读取数据的进程可以读取任意大小的数据块,而不管写入进程写入管道的数据块的大小是什么。此外,通过管道传递的数据是顺序的,从管道中读取出来的字节的顺序与它们被写入管道的顺序是完全一样的。
- 如果试图从一个当前为空的管道中读取数据,将会被阻塞直到至少有一个字节被写入到管道中为止。如果管道的写入端被关闭了,那么从管道中读取数据的进程在读完管道中剩余的所有数据之后将会看到文件结束,即
read()
返回0 - 管道的数据传递方向是单向的,一端用于写入,另一端用于读取
- 如果多个进程写入同一个管道,那么如果它们在一个时刻写入的数据量不超过PIPE_BUF字节(Linux系统下这个值为4096),那么就可以确保写入的数据不会发生相互混合的情况。
- 管道其实是一个在内核内存中维护的缓冲器,这个缓冲器的存储能力是有限的。一旦管道被填满之后,后续向该管道的写入操作就会被阻塞直到读者从管道中移除了一些数据为止。
创建与使用
pipe
系统调用可以创建一个新的管道:
1 |
|
如果调用成功,则会在数组filedes
中返回两个打开的文件描述符,filedes[0]
表示管道的读取端,filedes[1]
表示管道的写入端。
与所有的文件描述符一样,可以使用read
和write
系统调用在管道上执行I/O操作。一旦向管道的写入端写入数据之后立即就能从管道的读取端读取数据。管道上的read()
调用会读取的数据量为所请求的字节数与管道中当前存在的字节数两者之间较小的那个,而管道为空时则阻塞。也可以在管道上使用stdio
函数(printf()
、scanf()
等),只需要首先使用fdopen()
获取一个与filedes
中的某个描述符对应的文件流即可。
由于子进程会继承父进程的文件描述符的副本,因此可以通过管道来实现相关进程之间的通信。相关进程指的是这些进程来自于同一个祖先进程,且管道由祖先进程所创建。这些进程必须关闭未使用的管道文件描述符,否则会导致出错。
当所有子进程都关闭了管道的写入端的文件描述符之后,父进程在管道上的read()
就会结束并返回文件结束(0)。根据这一特性可以将管道作为一种进程同步的方法。这种方法可以同来协调一个进程的动作使之与多个其他(相关)进程匹配。当然,也可以使用其它的同步结构。
创建管道时,为管道两端分配文件描述符将会优先选择可用描述符中数值最小的,因此可以使用这一特性,将管道的输入和输出端绑定为进程的标准输入或输出。可以通过创建管道时先关闭标准输入/输出,或者是使用dup2
来复制文件描述符。
与shell命令通信
管道的一个常见用途是执行shell命令,并读取其输出或向其发送一些输入。popen
和pclose
函数可以简化这一任务:
1 |
|
popen
函数创建一个管道,然后创建一个子进程来执行shell,而shell又创建了一个子进程来执行command
字符串。mode
参数是一个字符串,它确定调用进程是从管道中读取数据('r'
),也就是命令的输出被送入到管道的输入端;还是将数据写入到管道中('w'
),也就是命令的输入来自于调用进程。
使用system()
时,shell命令的执行是被封装在单个函数调用中的;而使用popen()
时,调用进程是与shell命令并行运行的,然后会调用pclose()
。
FIFO
从语义上来讲,FIFO 与管道类似,它们两者之间最大的差别在于FIFO 在文件系统中拥有一个名称,并且其打开方式与打开一个普通文件是一样的。这样就能够将FIFO 用于非相关进程之间的通信(如客户端和服务器)。
一旦打开了FIFO,就能在它上面使用与操作管道和其他文件的系统调用一样的I/O系统调用。与管道一样,FIFO 也有一个写入端和读取端,并且从管道中读取数据的顺序与写入的顺序是一样的。FIFO 的名称也由此而来:先入先出。FIFO 有时候也被称为命名管道。
创建一个FIFO的命令如下:
1 | mkfifo [-m mode] pathname |
其中pathname
指的是创建的FIFO的名称,-m
选项用来指定权限mode
(工作方式与chmod
一样)
要在程序中创建一个FIFO,则可以使用mkfifo()
函数:
1 |
|
当一个进程打开一个FIFO的一端时,如果FIFO的另一端还没有被打开,那么该进程会被阻塞。但有些时候阻塞并不是期望的行为,而这可以通过在调用open()
时指定O_NONBLOCK标记来实现。如果打开FIFO是为了写入,并且还没有打开FIFO的另一端来读取数据,那么open()
调用会失败,并将errno
设置为ENXIO。
读写语义
管道和FIFO上read()
操作的语义可以总结为下表:
write()
操作的语义如下表:
System V IPC
简介
System V IPC是首先在System V中被广泛使用的三种IPC机制的名称并且之后被移植到了大多数UNIX实现中以及被加入了各种标准中。System V IPC包括三种不同的进程间通信机制:消息队列、信号量和共享内存。System V IPC的编程接口可汇总为下表:
消息队列
创建或打开
使用msgget()
系统调用可以创建一个新的消息队列,或者获得一个已有队列的标识符:
1 |
|
key
参数可以设置为IPC_PRIVATE,这样会创建一个新的IPC对象;或者是使用ftok()
函数生成。msgflg
参数可以为0,或者加上掩码IPC_CREAT(如果key不存在则创建新的消息队列)、IPC_EXCL(如果指定IPC_CREAT且key对应的队列已存在,则返回错误)。
消息交换
消息队列上的I/O操作可以使用下面的函数:
1 |
|
msgsnd
系统调用向消息队列写入一条信息,msqid
代表消息队列标识符,msgp
为要发送的消息,msgsz
为发送的消息长度,msgflg
为位掩码,用于控制msgsnd
的操作,目前只定义了IPC_NOWAIT这一个标记,代表执行非阻塞的发送操作。
向消息队列写入消息要求具备在该队列上的写权限。
msgrcv
系统调用从消息队列中读取(以及删除)一条消息,并将其内容复制到msgp
指向的缓冲区,maxmsgsz
代表mtext
的最大长度。读取消息的顺序无需与消息被发送的一致。可以根据mtype
字段的值来选择消息,而这个选择过程是由msgtyp
参数来控制的:如果msgtyp
等于0,则删除队列的第一条信息;如果大于0,则将队列中第一条mtype
等于msgtyp
的消息删除并返回给调用进程;如果小于0则将等待消息当成优先队列,返回mtype
最小,且数值小于等于msgtyp
绝对值的第一条消息。msgflg
是一个位掩码,可以为IPC_NOWAIT、MSG_EXCEPT、MSG_NOERROR这三个掩码其中0个或多个的或运算。
消息队列控制
msgctl
系统调用可以控制消息队列:
1 |
|
msqid
代表要操作的消息队列;cmd
指定队列上要执行的操作,它的取值为如下三个中的一个:
- IPC_RMID:立即删除消息队列对象及其关联的
msqid_ds
数据结构,队列中所有剩余消息丢失,所有被阻塞的读写进程会被唤醒 - IPC_STAT:将与这个消息队列关联的
msqid_ds
数据结构的副本放到buf
指向的缓冲区中 - IPC_SET:使用
buf
指向的缓冲区提供的值更新与这个消息队列关联的msqid_ds
数据结构中被选中的字段。
关联数据结构
每个消息队列都有一个关联的msqid_ds
数据结构,形式如下:
1 | struct msqid_ds{ |
缺点
- 消息队列是通过标识符引用的,而不是像大多数其他UNIX I/O机制那样使用文件描述符。因此无法使用基于文件描述符的I/O技术
- 使用键而不是文件名来标识消息队列,会增加额外的程序设计复杂性
- 消息队列无连接,内核不会维护引用队列的进程数
- 消息队列总数、消息大小以及单个队列的容量都有限制
信号量
简介
System V信号量不是用来在进程间传输数据的,而是用来同步进程的动作。信号量的一个常见用途是同步对一块共享内存的访问以防止出现一个进程在访问共享内存的同时另一个进程更新这块内存的情况。
一个信号量是一个由内核维护的整数,其值被限制为大于或等于0。在一个信号量上可以执行各种操作(即系统调用),包括:
- 将信号量设置为一个绝对值
- 在信号量当前值的基础上加/减1
- 等待信号量的值等于0
当减小一个信号量的值时,内核会将所有试图将信号量值降低到0之下的操作阻塞。如果信号量的当前值不为0,那么等待信号量的值等于0的调用进程将会发生阻塞。
使用System V 信号量的常规步骤如下。
- 使用
semget()
创建或打开一个信号量集。 - 使用
semctl()
SETVAL或SETALL操作初始化集合中的信号量。(只有一个进程需要完成这个任务。 - 使用
semop()
操作信号量值。使用信号量的进程通常会使用这些操作来表示一种共享资源的获取和释放。 - 当所有进程都不再需要使用信号量集之后使用
semctl()
IPC_RMID 操作删除这个集合。(只有一个进程需要完成这个任务。)
创建或打开
semget()
系统调用创建一个新信号量集,或者获取一个既有集合的标识符:
1 |
|
key
参数可以设置为IPC_PRIVATE,这样会创建一个新的IPC对象;或者是使用ftok()
函数生成。如果要创建一个新的信号量集,那么nsem
参数会指定信号量的数量(必须大于0);如果要获取一个既有集的标识符,则nsem
参数需要小于或等于集合大小。semflg
参数为一个位掩码,指定了施加于新信号量集之上的权限或需检查的一个既有集合的权限。
控制信号量
semctl()
系统调用在一个信号量集或集合中的单个信号量上执行各种控制操作:
1 |
|
semid
参数是操作所施加的信号量集的标识符。对于那些在单个信号量上执行的操作,semnum
参数标识出了集合中的具体信号量。对于其他操作则会忽略这个参数,并且可以将其设置为0。cmd
参数指定了需执行的操作。
第四个参数arg
是一个union
类型的变量,需要在程序中显式定义这个类型:
1 | union semun{ |
cmd
参数可以设置的值及其对应的操作如下:
- 常规控制操作:
- IPC_RMID:立即删除信号量集及其关联的
semid_ds
数据结构 - IPC_STAT:在
arg.buf
指向的缓冲器中放置一份与这个信号量集相关联的semid_ds
数据结构的副本 - IPC_SET:使用
arg.buf
指向的缓冲器中的值来更新与信号量集关联的semid_ds
数据结构中的字段
- IPC_RMID:立即删除信号量集及其关联的
- 获取和初始化信号量值
- GETVAL:返回由
semid
指定的信号量中第semnum
个信号量的值 - SETVAL:将
semid
指定的信号量集中的第semnum
个信号量的值初始化为arg.val
- GETALL:获取由
semid
指向的信号量集中所有信号量的值并将它们放在arg.array
指向的数组中。程序员必须要确保该数组具备足够的空间。这个操作将忽略semnum
参数 - SETALL:使用
arg.array
指向的数组中的值初始化semid
指向的集合中的所有信号量。这个操作将忽略semnum
参数
- GETVAL:返回由
- 获取单个信号量的信息
- GETPID:返回上一个在该信号量上执行
semop()
进程的ID - GETNCNT:返回正在等待信号量值增长的进程数
- GETZCNT:返回正在等待信号量的值变为0的进程数
- GETPID:返回上一个在该信号量上执行
信号量关联数据结构
每个信号量集都有一个关联的semid_ds
数据结构,其形式如下:
1 | struct semid_ds{ |
信号量操作
semop
系统调用在semid
标识的信号量集中的信号量上面执行一个或者多个操作:
1 |
|
sops
参数是一个指向数组的指针,数组中包含了需要执行的操作,nsops
参数给出了数组的大小(数组至少需包含一个元素)。操作将会按照在数组中的顺序以原子的方式被执行。sop
数组中的元素是形式如下的结构:
1 | struct sembuf{ |
如果存在多个因减小一个信号量值而发生阻塞的进程,它们对该信号量减去的值是一样的,那么当条件允许时无法确定到底哪个进程会首先被允许执行操作。另一方面,如果多个因减小一个信号量值而发生阻塞的进程对该信号量减去的值是不同的,那么会按照先满足条件先服务的顺序来进行。
假设一个进程在调整完一个信号量值(如减小信号量值使之等于0)之后终止了,不管是有意终止还是意外终止。在默认情况下,信号量值将不会发生变化。这样就可能会给其他使用这个信号量的进程带来问题。为避免这种问题的发生,在通过semop()
修改一个信号量值时可以使用SEM_UNDO标记。当指定这个标记时,内核会记录信号量操作的效果,然后在进程终止时撤销这个操作。不管进程是正常终止还是非正常终止,撤销操作都会发生。
缺点
- 信号量是通过标识符而不是大多数UNIX I/O 和IPC 所采用的文件描述符来引用的。这使得执行诸如同时等待一个信号量和文件描述符的输入之类的操作就会变得比较困难。
- 使用键而不是文件名来标识信号量增加了额外的编程复杂度。
- 创建和初始化信号量需要使用单独的系统调用意味着在一些情况下必须要做一些额外的编程工作来防止在初始化一个信号量时出现竞争条件。
- 内核不会维护引用一个信号量集的进程数量。这就给确定何时删除一个信号量集增加了难度,并且难以确保一个不再使用的信号量集会被删除。
- System V 提供的编程接口过于复杂。在通常情况下,一个程序只会操作一个信号量。同时操作集合中多个信号量的能力有时侯是多余的。
- 信号量的操作存在诸多限制。这些限制是可配置的,但如果一个应用程序超出了默认限制的范围,那么在安装应用程序时就需要完成额外的工作。
共享内存
简介
共享内存允许两个或多个进程共享物理内存的同一块区域(通常被称为段)。由于一个共享内存段会成为一个进程用户空间内存的一部分,因此这种IPC机制无需内核介入。所有需要做的就是让一个进程将数据复制进共享内存中,并且这部分数据会对其他所有共享同一个段的进程可用。
另一方面,共享内存这种IPC机制不由内核控制意味着通常需要使用某些同步方法,使得进程不会出现同时访问共享内存的情况。
为使用一个共享内存段通常需要执行下面的步骤。
- 调用
shmget()
创建一个新共享内存段或取得一个既有共享内存段的标识符(即由其他进程创建的共享内存段)。这个调用将返回后续调用中需要用到的共享内存标识符。 - 使用
shmat()
来附上共享内存段,即使该段成为调用进程的虚拟内存的一部分。 - 此刻在程序中可以像对待其他可用内存那样对待这个共享内存段。为引用这块共享内存,程序需要使用由
shmat()
调用返回的addr
值,它是一个指向进程的虚拟地址空间中该共享内存段的起点的指针。 - 调用
shmdt()
来分离共享内存段。在这个调用之后,进程就无法再引用这块共享内存了。这一步是可选的,并且在进程终止时会自动完成这一步。 - 调用
shmctl()
来删除共享内存段。只有当当前所有附加内存段的进程都与之分离之后内存段才会被销毁。只有一个进程需要执行这一步。
创建或打开
shmget()
系统调用创建一个新的共享内存段或者获取一个既有段的标识符,新创建的内存段中,所有内容会被初始化为0:
1 |
|
key
参数是IPC_PRIVATE值或由ftok()
生成的键。size
代表要分配的字节数,会被提升到最近的系统分页大小的整数倍。shmflg
参数用于控制shmget
的操作,可以为IPC_CREAT、IPC_EXCL、SHM_HUGETLB(允许使用巨页的共享内存段)、SHM_NORESERVE这四个值进行或运算的结果。
使用
shmat()
系统调用可以将shmid
标识的共享内存段附加到调用进程的虚拟地址空间:
1 |
|
shmaddr
和shmflg
位掩码参数中SHM_RND位的设置控制着段如何被附加上去:
- 如果
shmaddr
为NULL,那么段会被附加到内核选择的一个合适的地址 - 如果
shmaddr
不为NULL,且没有设置SHM_RND,则会被附加到shmaddr
指定的地址处,它必须是系统分页大小的倍数 - 如果
shmaddr
不为NULL,并且设置了SHM_RND,那么段会被映射到的地址为在shmaddr
中提供的地址被舍入到最近的常量SHMLBA(shared memory low boundaryaddress)的倍数。这个常量等于系统分页大小的某个倍数。
要附加一个共享内存段以供只读访问,那么就需要在shmflg
中指定SHM_RDONLY标记。如果在shmflg
中指定了SHM_REMAP,在指定了这个标记之后shmaddr
的值不能为NULL。这个标记要求shmat()
调用替换起点在shmaddr
处长度为共享内存段的长度的任何既有共享内存段或内存映射。
当一个进程不再需要访问一个共享内存段时就可以调用shmdt()
,将该段分离出其虚拟地址空间。shmaddr
参数标识出了待分离的段,它应该是由之前的shmat()
调用返回的一个值:
1 |
|
通过fork()
创建的子进程会继承其父进程附加的共享内存段。因此,共享内存为父进程和子进程之间的通信提供了一种简单的IPC 方法。
在exec()
操作之后,所有附加的共享内存段都会被分离。在进程终止之后共享内存段也会自动被分离。
控制操作
shmctl()
系统调用在shmid
标识的共享内存段上执行一组控制操作:
1 |
|
cmd
参数规定了待执行的控制操作,buf
参数与控制操作有关,只有部分操作需要指定它的值,其余操作可以设为NULL。
可以执行的操作如下:
- 常规控制操作:
- IPC_RMID:标记这个共享内存段及其关联
shmid_ds
数据结构以便删除。如果当前没有进程附加该段,那么就会执行删除操作,否则就在所有进程都已经与该段分离(即当shmid_ds
数据结构中shm_nattch
字段的值为0 时)之后再执行删除操作。 - IPC_STAT:将与这个共享内存段关联的
shmid_ds
数据结构的一个副本放置到buf
指向的缓冲区中。 - IPC_SET:使用
buf
指向的缓冲区中的值来更新与这个共享内存段相关联的shmid_ds
数据结构中被选中的字段。
- IPC_RMID:标记这个共享内存段及其关联
- 加锁和解锁共享内存:
- SHM_LOCK:操作将一个共享内存段锁进内存。
- SHM_UNLOCK:操作为共享内存段解锁以允许它被交换出去。
共享内存关联数据结构
每个共享内存段都有一个关联的shmid_ds
数据结构,其形式如下:
1 | struct shmid_ds{ |
POSIX IPC
简介
POSIX.1b实时扩展定义了一组IPC机制,它们与System V IPC 机制类似。这组机制中包括消息队列、信号量和共享内存。它们的编程接口总结如下:
要访问一个POSIX IPC对象就必须要通过某种方式来识别出它。规定的唯一一种用来标识POSIX IPC对象的可移植的方式是使用以斜线打头后面跟着一个或多个非斜线字符的名字,如/myobject
。
消息队列
打开、关闭和断开
打开一个消息队列可以使用mq_open
函数,调用成功则返回一个消息队列描述符:
1 |
|
其中,name
参数为消息队列的标识;oflag
参数是位掩码,可以包含的值有O_CREAT、O_EXCL、O_RDONLY、O_WRONLY、O_RDWR和O_NONBLOCK;mode
参数是一个位掩码,用于指定施加于消息队列的权限,它可取的值与文件上的掩码值一样;attr
参数指定了新消息队列的特性,如果使用NULL则使用默认特性创建队列。
消息队列描述符和打开着的消息队列之间的关系,与文件描述符和打开着的文件描述符之间的关系类似。消息队列描述符是一个进程级别的句柄,它引用了系统中打开着的消息队列描述表中的一个条目,而该条目则引用了一个消息队列对象。
在fork()
中,子进程会接收其父进程的消息队列描述符的副本,并且这些描述符会引用同样的打开着的消息队列描述符。当一个进程执行了一个exec()
或终止时,所有其打开的消息队列描述符会被关闭。
消息队列的关闭使用mq_close
函数:
1 |
|
关闭一个消息队列并不会删除该队列。要删除队列则需要使用mq_unlink()
:
1 |
|
消息队列特性
消息队列所具有的特性被保存在mq_attr
结构中,它的形式如下:
1 | struct mq_attr{ |
要获取一个消息队列的信息,可以使用mq_getattr()
函数:
1 |
|
要修改消息队列特性,可以使用mq_setattr()
函数:
1 |
|
交换消息
要发送消息到消息队列,可以使用mq_send
函数:
1 |
|
其中,msg_len
指定了msg_ptr
指向的消息的长度,其值必须小于或等于队列的mq_msgsize
特性,否则会返回EMSGSIZE错误。msg_prio
表示消息的优先级,消息在队列中是按照优先级的倒序排列,0表示优先级最低。
如果消息队列已经满了(即已经达到了队列的mq_maxmsg
限制),那么后续的mq_send()
调用会阻塞直到队列中存在可用空间为止,或者在O_NONBLOCK标记起作用时立即失败并返回EAGAIN错误。
mq_receive
函数从mqdes
引用的消息队列中删除一条优先级最高、存在时间最长的消息,并把删除的消息放置在msg_ptr
指向的缓冲区:
1 |
|
不管消息的实际大小是什么,msg_len
(即msg_ptr
指向的缓冲区的大小)必须要大于或等于队列的mq_msgsize
特性,否则mq_receive()
就会失败并返回EMSGSIZE错误。如果msg_prio
不为NULL,那么接收到的消息的优先级会被复制到msg_prio
指向的位置处。
如果消息队列当前为空,那么mq_receive()
会阻塞直到存在可用的消息,或在O_NONBLOCK标记起作用时会立即失败并返回EAGAIN 错误。
如果要为发送和接收消息设置超时时间,则可以使用下面的两个函数:
1 |
|
消息通知
POSIX 消息队列能够接收之前为空的队列上有可用消息的异步通知(即队列从空变成了非空)。这个特性意味着已经无需执行一个阻塞的调用,或将消息队列描述符标记为非阻塞并在队列上定期执行mq_receive()
调用。进程可以选择通过信号的形式,或通过在一个单独的线程中调用一个函数的形式来接收通知。
mq_notify
函数使得调用进程在消息描述符mqdes
引用的空队列有一条消息进入时,可以接收到通知:
1 |
|
notification
参数指定了进程接收通知的机制。
关于消息通知需要注意以下几点:
- 在任何一个时刻都只有一个进程(“注册进程”)能够向一个特定的消息队列注册接收通知。也就是说一个消息队列只能接受一个进程的注册,只给该进程发送通知
- 只有当一条新消息进入之前为空的队列时,注册进程才会收到通知
- 当向注册进程发送了一个通知之后就会删除注册信息
- 只有当前不存在其他在该队列上调用
mq_receive()
而发生阻塞的进程时,注册进程才会收到通知。 - 一个进程可以通过在调用
mq_notify()
时传入一个值为NULL的notification
参数,撤销自己在消息通知上的注册信息。
POSIX和System V消息队列比较
POSIX消息队列有如下优势:
- 接口简单,且与传统的UNIX文件模型一致
- 使用引用计数,简化了确定何时删除一个对象的任务
- 消息通知特性允许一个(单个)进程能够在一条消息进入之前为空的队列时异步地通过信号或线程的实例化来接收通知。
- 在 Linux(不包括其他UNIX 实现)上可以使用
poll()
、select()
以及epoll
来监控POSIX消息队列
但是POSIX消息队列也有一些劣势:
- 可移植性稍差
- POSIX消息队列严格按照优先级排序,不能像System V消息队列按照类型选择消息
信号量
概述
POSIX信号量有两种:
- 命名信号量:这种信号量拥有一个名字。通过使用相同的名字调用
sem_open()
,不相关的进程能够访问同一个信号量。 - 未命名信号量:这种信号量没有名字,相反,它位于内存中一个预先商定的位置处。未命名信号量可以在进程之间或一组线程之间共享。当在进程之间共享时,信号量必须位于一个共享内存区域中(System V、POSIX或
mmap()
)。当在线程之间共享时,信号量可以位于被这些线程共享的一块内存区域中(如在堆上或在一个全局变量中)。
POSIX信号量的运作方式与System V信号量类似,即POSIX信号量是一个整数,其值是不能小于0 的。如果一个进程试图将一个信号量的值减小到小于0,那么取决于所使用的函数,调用会阻塞或返回一个表明当前无法执行相应操作的错误。
命名信号量
下列函数用于命名信号量的使用过程:
sem_open()
函数打开或创建一个信号量并返回一个句柄以供后续调用使用,如果这个调用会创建信号量的话,还会对所创建的信号量进行初始化。sem_post(sem)
和sem_wait(sem)
函数分别递增和递减一个信号量值。sem_getvalue()
函数获取一个信号量的当前值。sem_close()
函数删除调用进程与它之前打开的一个信号量之间的关联关系。sem_unlink()
函数删除一个命名信号量,并将其标记为在所有进程关闭该信号量时删除该信号量。
打开命名信号量sem_open()
函数的使用方法如下:
1 |
|
其中,name
为信号量的名称,oflag
参数是一个位掩码,它确定是打开一个已有的信号量(将oflag
设置为0)还是创建并打开一个新的信号量(oflag
的值为O_CREAT且name
对应的信号量不存在)。如果这一函数被用来打开一个既有信号量,则调用时只需要传入name
和oflag
参数;如果要创建一个新的信号量,则还需要指定mode
和value
参数。其中mode
是一个位掩码,指定了新信号量的权限,它可取的值与文件上的位值一样;value
是一个无符号整数,指定了信号量的初始值。
当一个进程打开一个命名信号量时,系统会记录进程与信号量之间的关联关系。sem_close()
函数会终止这种关联关系(即关闭信号量),释放系统为该进程关联到该信号量之上的所有资源,并递减引用该信号量的进程数:
1 |
|
打开的命名信号量在进程终止或进程执行了一个exec()
时会自动被关闭。
关闭一个信号量并不会删除这个信号量,而要删除信号量则需要使用sem_unlink()
。name
标识的信号量将会在所有进程都使用完这个信号量时就被销毁。
1 |
|
信号量操作
要等待一个信号量,可以使用下面的函数:
1 |
|
要发布一个信号量则可以使用sem_post()
函数:
1 |
|
sem_post()
调用会将sem
引用信号量的值加1。如果在sem_post
调用之前信号量的值为0,并且其它某个进程或者线程因等待递减这个信号量而阻塞,则等待进程会被唤醒。哪个等待进程会被唤醒与系统的调度策略有关。
要获取信号量的当前值,则可以使用sem_getvalue()
函数:
1 |
|
这一函数调用会将sem
引用的信号量的当前值通过sval
指向的变量返回。如果一个或多个进程(或线程)当前正在阻塞以等待递减信号量值,那么sval
中的返回值将取决于实现,在Linux系统中会返回0。
未命名信号量
未命名信号量(也被称为基于内存的信号量)是类型为sem_t
并存储在应用程序分配的内存中的变量。通过将这个信号量放在由几个进程或线程共性的内存区域中就能够使这个信号量对这些进程或线程可用。
操作未命名信号量所使用的函数与操作命名信号量使用的函数是一样的,除此之外还需要使用sem_init()
和sem_destroy(sem)
两个函数。
sem_init()
使用value
参数指定的值,来对sem
指向的未命名信号量进行初始化:
1 |
|
pshared
参数表明信号量是在线程还是进程间共享。如果pshared
等于0,那么信号量将会在调用进程中的线程间进行共享。在这种情况下,sem
通常被指定成一个全局变量的地址或分配在堆上的一个变量的地址。线程共享的信号量具备进程持久性,它在进程终止时会被销毁;如果pshared
不等于0,那么信号量将会在进程间共享。在这种情况下,sem
必须是共享内存区域(一个POSIX 共享内存对象、一个使用mmap()
创建的共享映射、或一个System V共享内存段)中的某个位置的地址。
要销毁未命名信号量,则需要使用sem_destroy()
函数:
1 |
|
这一函数将会销毁信号量sem
,其中sem
必须是一个之前使用sem_init()
进行初始化的未命名信号量。只有不存在进程或者线程在等待一个信号量时,才能安全销毁这个信号量。
共享内存
概述
POSIX共享内存能够让无关进程共享一个映射区域而无需创建一个相应的映射文件。要使用 POSIX 共享内存对象需要完成下列任务。
- 使用
shm_open()
函数打开一个与指定的名字对应的对象。shm_open()
函数与open()
系统调用类似,它会创建一个新共享对象或打开一个既有对象。shm_open()
会返回一个引用该对象的文件描述符。 - 将上一步中获得的文件描述符传入
mmap()
调用并在其flags
参数中指定MAP_SHARED。这会将共享内存对象映射进进程的虚拟地址空间。与mmap()
的其他用法一样,一旦映射了对象之后就能够关闭该文件描述符而不会影响到这个映射。
创建共享内存对象
创建一个共享内存对象的方法如下:
1 |
|
其中,name
参数代表待创建或者打开的共享内存对象;oflag
参数用于改变调用行为,可以对下面几个值取或运算:O_CREAT(如果对象不存在则创建对象)、O_EXCL(确保调用者是对象的创建者)、O_RDONLY(打开只读访问)、O_RDWR(打开读写访问)、O_TRUNC(将对象长度截断为0)。在一个新共享内存对象被创建时,其所有权和组所有权将根据调用shm_open()
的进程的有效用户和组ID来设定,对象权限将会根据mode
参数中设置的掩码值来设定。mode
参数能取的位值与文件上的权限位值是一样的。
shm_open()
返回的文件描述符会设置close-on-exec
标记,因此当程序执行了一个exec()
时文件描述符会被自动关闭。
一个新共享内存对象被创建时其初始长度会被设置为0。这意味着在创建完一个新共享内存对象之后通常在调用mmap()
之前需要调用ftruncate()
来设置对象的大小,以及后续使用ftruncate()
来扩大或者收缩共享内存对象。在扩展一个共享内存对象时,新增加的字节会被自动初始化为0。
在任何时候都可以在shm_open()
返回的文件描述符上使用fstat()
以获取一个stat
结构,该结构的字段会包含与这个共享内存对象相关的信息。使用fchmod()
和fchown()
能够分别修改共享内存对象的权限和所有权。
删除共享内存对象
POSIX共享内存对象至少具备内核持久性,即它们会持续存在直到被显式删除或系统重启。当不再需要一个共享内存对象时就应该使用shm_unlink()
删除它:
1 |
|
shm_unlink()
函数会删除通过name
指定的共享内存对象。删除一个共享内存对象不会影响对象的既有映射(它会保持有效直到相应的进程调用munmap()
或终止),但会阻止后续的shm_open()
调用打开这个对象。一旦所有进程都解除映射这个对象,对象就会被删除,其中的内容会丢失。
SOCKET
简介
socket是一种IPC方法,它允许位于同一主机(计算机)或使用网络连接起来的不同主机上的应用程序之间交换数据。在一个典型的客户端/服务器场景中,应用程序使用socket进行通信的方式如下:
- 各个应用程序创建一个socket
- 服务器将自己的socket绑定到一个地址上,使得客户端可以定位到它的位置
socket存在于一个通信domain中,它确定了识别出一个socket的方法,并确定其通信范围。现代操作系统至少支持UNIX、IPv4和IPv6这三个domain。它们的区别如下图:
每一个socket实现都至少提供了流和数据报这两种类型。流socket提供了一个可靠的双向字节流通信信道,可以保证发送的数据完整地到达接收端,但是数据不存在消息边界的概念。它类似于使用一对允许在两个应用程序之间双向通信的管道,但是socket允许在网络上进行通信。流socket的正常工作需要一对相互连接的socket,因此它也常常被称为面向连接的。
而数据报socket则允许数据以数据报的消息形式进行交换,因此数据的消息边界被保留下来。但是这种方式的数据传输是不可靠的,消息到达可能是无序的、重复的甚至无法到达。它属于无连接的socket。
系统调用
要创建一个socket,可以使用socket()
系统调用:
1 |
|
其中domain
参数指定了socket的通信domain;type
参数指定了socket类型,创建流socket时为SOCK_STREAM,创建数据报socket时被设为SOCK_DGRAM;protocol
参数通常被设置为0。
如果要将一个socket绑定到一个地址上,则可以使用bind()
系统调用:
1 |
|
其中,sockfd
参数是一个socket()
调用获得的文件描述符,addr
参数为一个指针,指向socket绑定的地址的结构,这个数据结构类型取决于socket domain。addrlen
参数则指定了地址结构数据的大小。一般来说,会将一个服务器的socket绑定到一个众所周知的地址,这个地址是固定的,且客户端应用程序提前知道。
sockaddr
数据结构的定义如下:
1 | struct sockaddr{ |
要关闭一个socket,则可以使用close()
函数,这会将双向通信通道的两端全部关闭。如果要实现更精确的控制,则可以使用shutdown()
函数:
1 |
|
其中,how
参数的值可以为如下几种:
- SHUT_RD:关闭连接的读端,之后的读操作将会返回文件结尾
- SHUT_WR:关闭连接的写端,后续再执行写操作则会产生SIGPIPE信号以及EPIPE错误
- SHUT_RDWR:将连接的读写端全部关闭
需要注意的是,shutdown()
并不会关闭文件描述符,必须调用close()
关闭。
流socket
流socket的运作原理如下:
socket()
系统调用将会创建一个socket,这相当于安装一个电话。为了使得两个应用程序能够通信,每个应用程序都必须要创建一个socket。- 一个应用程序在进行通信之前必须要将其socket连接到另一个应用程序的socket上。两个socket的连接过程如下:
- 一个应用程序调用
bind()
将socket绑定到一个地址上,然后调用listen()
通知内核它接受接入连接的意愿。 - 其它应用程序通过调用
connect()
建立连接,同时指定需要连接的socket地址 - 调用
listen()
的应用程序使用accept()
接受连接。如果在对等应用程序调用connect()
之前执行了accept()
,那么accept()
操作会阻塞。
- 一个应用程序调用
- 一旦建立了一个连接之后,就可以在应用程序之间进行双向数据传输,直到其中一个使用
close()
关闭连接为止。通信是通过传统的read()
和write()
系统调用,或通过一些提供了额外功能的socket特定系统调用(如send()
和recv()
)来完成的。
流socket通常可以分为主动和被动两种。在默认情况下,使用socket()
创建的socket是主动的。一个主动的socket可用在connect()
调用中,来建立一个到被动socket的连接。而一个被动socket(也被称为监听socket)是一个通过调用listen()
以被标记成允许接入连接的socket。在大多数使用流socket的应用程序中,服务器会执行被动式打开,而客户端会执行主动式打开。
这一过程中涉及到的系统调用包括:
1 |
|
数据报socket
数据报socket的运作原理如下:
- 所有需要发送和接收数据报的应用程序都需要使用
socket()
创建一个数据报socket。这可以理解为创建两个邮箱。 - 为允许另一个应用程序发送其数据报,一个应用程序需要使用
bind()
将其socket绑定到一个众所周知的地址上。一般来讲,一个服务器会将其socket绑定到一个众所周知的地址上,而一个客户端会通过向该地址发送一个数据报来发起通信。 - 要发送一个数据报,一个应用程序需要调用
sendto()
,它接收的其中一个参数是数据报发送到的socket的地址。 - 为接收一个数据报,一个应用程序需要调用
recvfrom()
,它在没有数据报到达时会阻塞。由于recvfrom()
允许获取发送者的地址,因此可以在需要的时候发送一个响应。 - 当不再需要socket时,应用程序需要使用
close()
关闭socket。
其中涉及到的系统调用有:
1 |
|
其中flags
是一个位掩码,控制socket特定的I/O特性。对于recvfrom
函数来说,如果不关心发送者的地址,那么可以将src_addr
和addrlen
都指定为NULL,此时等价于使用recv()
来接收一个数据报。
不管length
的参数值是什么,recvfrom()
只会从一个数据报socket中读取一条消息。如果消息的大小超过了length字节,那么消息会被静默地截断为length字节。
UNIX domain
在UNIX domain中,socket地址使用路径名来表示,地址结构的定义如下:
1 | struct sockaddr_un{ |
关于UNIX domain socket的使用需要注意:
- 无法将一个socket绑定到一个既有路径名上(
bind()
会失败并返回EADDRINUSE 错误)。通常会将一个socket绑定到一个绝对路径名上,这样这个socket就会位于文件系统中的一个固定地址处 - 一个socket只能绑定到一个路径名上,相应地,一个路径名只能被一个socket绑定
- 无法使用
open()
打开一个socket - 当不再需要一个socket时,可以使用
unlink()
(或remove()
)删除其路径名条目
对于UNIX domain socket来讲,数据报的传输是在内核中发生的,并且也是可靠的。所有消息都会按顺序被递送,并且也不会发生重复的状况。
Linux 特有的一项特性是,它允许将一个UNIX domain socket绑定到一个名字上,但不会在文件系统中创建该名字。要创建一个抽象绑定,就需要将sun_path
字段的第一个字节指定为null字节(\0)。这样就能够将抽象socket名字与传统的UNIX domain socket路径名区分开来。余下的字节为socket定义了抽象名称。
socket文件的所有权和权限决定了哪些进程能够与这个socket进行通信。
- 要连接一个UNIX domain流socket,需要在该socket文件上拥有写权限。
- 要通过一个UNIX domain数据报socket发送一个数据报,需要在该socket文件上拥有写权限。
- 此外,需要在存放socket路径名的所有目录上都拥有执行(搜索)权限。
有时候需要让单个进程创建一对socket并将它们连接起来。socketpair
系统调用可以快捷地实现这一操作:
1 |
|
其中,domain
参数必须为AF_UNIX,type
可以为SOCK_DGRAM或SOCK_STREAM,protocol
参数必须为0,sockfd
数组返回引用这两个相互连接socket的文件描述符。
Internet domain
Internet domain流socket是基于TCP之上的,它们提供了可靠的双向字节流通信信道。而Internet domain数据报socket是基于UDP之上的,它属于不可靠连接。Internet domain socket的地址有IPv4和IPv6两种,它们分别对应的数据结构如下:
1 | struct sockaddr_in{ |
1 | struct sockaddr_in6{ |
也可以使用通用的sockaddr_storage
结构,这个数据结构的空间足以存储任意类型的socket地址:
1 |
|
由于IP地址在计算机中被存储为二进制形式,不方便阅读,下面两个函数可以方便将IPv4和IPv6地址的二进制形式和点分十进制表示法或者十六进制字符串表示法之间转换:
1 |
|
其中,inet_pton()
用于将src_str
中所包含的字符串转换为网络字节序的二进制IP地址,domain
参数为AF_INET或者AF_INET6,转换得到的地址会被放在addrptr
指向的结构中;而inet_ntop()
函数执行逆向的转换,addrptr
指向一个待转换的in_addr
或者in_addr6
结构,得到的字符串会被存放在dst_str
指向的缓冲区中,len
参数为缓冲区的大小。如果len
的值太小,不足以存放转换后的地址,那么函数会返回NULL并将errno设置为ENOSPC。
为了正确计算缓冲区大小,可以使用下面两个常量:
1 |
一个多字节的整数可能会以大端序或者小端序这两种不同的顺序来存储。在特定主机上使用的字节序称为主机字节序,它与硬件架构有关;而在网络中传递的顺序被称为网络字节序,它被规定为大端序。因此,在将整数存储进socket地址结构之前则需要将这些值转换成网络字节序,可以使用下面四个函数完成这一操作:
1 |
|
其它用法
recv()
和send()
这一组系统调用可以在已连接的套接字上执行I/O操作,它们提供了专属于套接字的功能:
1 |
|
其中,前3个参数于read()
和write()
一样,最后一个参数为位掩码,用于修改I/O操作的行为。
对于recv
函数,flags
可以取下面的值:
- MSG_DONTWAIT:让
recv
以非阻塞方式执行,如果没有数据可用则立即返回并生成错误码EAGAIN - MSG_OOB:在套接字上接收带外数据,带外数据指的是允许发送端将传输的数据标记为高优先级。任意时刻最多只有1字节数据可以被标记为带外数据。
- MSG_PEEK:从套接字缓冲区中获取一份请求字节的副本,但不会将请求的字节从缓冲区中实际移除
- MSG_WAITALL:如果无法接收
length
字节则会阻塞,直到可以从缓冲区接收length
字节的数据
而对于send
函数,flags
可以取下面的值:
- MSG_DONTWAIT:以非阻塞方式执行,如果没有数据可用则立即返回并生成错误码EAGAIN
- MSG_MORE:在Linux系统下,如果指定了MSG_MORE 标记,那么数据会打包成一个单独的数据报。仅当下一次调用中没有指定该标记时,数据才会传输出去
- MSG_NOSIGNAL:当在已连接的流式套接字上发送数据时,如果连接的另一端已经关闭了,指定该标记后将不会产生SIGPIPE信号。相反,
send()
调用会失败,伴随的错误码为EPIPE。 - MSG_OOB:在流式套接字上发送带外数据
如果要将磁盘上的文件内容不做修改地传出去,则可以使用sendfile()
系统调用。此时,文件内容会被直接传送到套接字上,而不会经过用户空间,因此传输效率更高:
1 |
|
其中,in_fd
代表输入文件描述符,out_fd
代表输出文件描述符。要求out_fd
必须指向一个套接字,而in_fd
指向的文件可以进行mmap()
操作。参数offset
可以指向一个off_t
类型的值,指定输入文件的偏移量,代表in_fd
指向的文件从这一位置开始可以传输字节,此时数据传输不会修改in_fd
的文件偏移量;也可以设置为NULL,此时会从当前的文件偏移量处开始传输,且传输时会更新文件偏移量。count
参数指定了请求传输的字节数。
套接字选项能影响到套接字操作的多个功能。下面两个系统调用可以设定和获取套接字选项:
1 |
|
其中,sockfd
代表指向套接字的文件描述符,level
代表套接字选项适用的协议,optname
标识了希望设定或者取出的套接字选项,optval
是一个指向缓冲区的指针,用于指定或者返回选项的值,optlen
指定了缓冲区的空间大小。
其它I/O模型
概述
由于非阻塞式I/O以及多进程/多线程有各自的局限性,因此一些使用场景下我们需要考虑下面的备选方案:
- I/O多路复用允许进程同时检查多个文件描述符以找出它们中的任何一个是否可执行I/O操作。系统调用
select()
和poll()
用来执行I/O多路复用。 - 信号驱动I/O是指当有输入或者数据可以写到指定的文件描述符上时,内核向请求数据的进程发送一个信号。进程可以处理其他的任务,当I/O操作可执行时通过接收信号来获得通知。当同时检查大量的文件描述符时,信号驱动I/O相比
select()
和poll()
有显著的性能提升。 - epoll API是Linux专有的特性,同I/O多路复用API一样,epoll API允许进程同时检查多个文件描述符,看其中任意一个是否能执行I/O 操作。同信号驱动I/O一样,当同时检查大量文件描述符时,epoll能提供更好的性能。
实际上,I/O多路复用、信号驱动I/O以及epoll都是用来实现同一个目标的技术—同时检查多个文件描述符,看它们是否准备好了执行I/O 操作。但是它们不会执行实际的I/O操作,仍需要使用其它的系统调用来完成I/O操作。
有两种文件描述符准备就绪的通知模式:
- 水平触发通知:如果文件描述符上可以非阻塞地执行 I/O系统调用,此时认为它已经就绪。
- 边缘触发通知:如果文件描述符自上次状态检查以来有了新的I/O 活动(比如新的输入),此时需要触发通知。
epoll API同时支持上述两种通知,信号驱动I/O仅支持边缘触发,多路复用技术仅支持水平触发。
I/O多路复用
简介
I/O多路复用允许我们同时检查多个文件描述符,看其中任意一个是否可执行I/O操作。我们可以在普通文件、终端、伪终端、管道、FIFO、套接字以及一些其他类型的字符型设备上使用select()
和poll()
来检查文件描述符。这两个系统调用都允许进程要么一直等待文件描述符成为就绪态,要么在调用中指定一个超时时间。
系统调用
select()
系统调用会一直阻塞,直到一个或者多个文件描述符集合成为就绪态:
1 |
|
其中,timeout
参数设定select
阻塞的时间上限,如果设置为NULL则代表一直阻塞;参数nfds
通常设为比3个文件描述符集合中所包含的最大文件描述符号还要大1,这个参数让select()
变得更有效率;readfds
,writefds
和exceptfds
分别代表检测输入是否就绪,输出是否就绪以及异常情况是否发生的文件描述符集合,它们在select()
调用之后会被修改,其中包含处于就绪态的文件描述符集合。
数据类型fd_set
通常以位掩码的形式来实现,但是我们无需知道细节,关于文件描述符集合的操作通过如下四个宏来完成:
1 |
|
如果select()
返回一个正数,则代表至少一个文件描述符到达就绪态,此时需要使用FD_ISSET
对三个文件描述符集合进行检查。
系统调用poll()
执行的任务同select()
很相似。两者间主要的区别在于我们要如何指定待检查的文件描述符,poll()
系统调用提供一列文件描述符,并在每个文件描述符上标明我们感兴趣的事件。它的用法如下:
1 |
|
timeout
参数决定了poll
的阻塞行为,如果它的值为-1则会一直阻塞直到fds
中列出的描述符有一个达到就绪态;如果为0则不会阻塞,仅仅是检查是否有处于就绪态的文件描述符;如果大于0则代表阻塞的时间上限(毫秒)。events
和fevents
参数能够使用的位掩码如下:
随着待检查的文件描述符数量的增加,select()
和poll()
所占用的CPU时间也会随之增加。对于需要检查大量文件描述符的程序来说,这就产生了问题。而且内核并不会在每次调用成功之后记录下检查的文件描述符,当重复检查相同的文件描述符时会带来糟糕的性能延展性。
信号驱动I/O
在信号驱动I/O中,当文件描述符上可执行I/O操作时,进程请求内核为自己发送一个信号。之后进程就可以执行任何其他的任务,直到I/O 就绪为止,此时内核会发送信号给进程。要使用信号驱动I/O,程序需要按照如下步骤来执行。
- 为内核发送的通知信号安装一个信号处理例程。默认情况下,这个通知信号为SIGIO。
- 设定文件描述符的属主,也就是当文件描述符上可执行I/O时会接收到通知信号的进程或进程组。通常我们让调用进程成为属主。设定属主可通过
fcntl()
的F_SETOWN操作来完成 - 通过设定O_NONBLOCK标志使能非阻塞I/O。
- 通过打开O_ASYNC标志使能信号驱动I/O。这可以和上一步合并为一个操作,因为它们都需要用到
fcntl()
的F_SETFL 操作。 - 调用进程现在可以执行其他的任务。当I/O操作就绪时,内核为进程发送一个信号,然后调用在第1步中安装好的信号处理例程。
- 信号驱动I/O提供的是边缘触发通知。这表示一旦进程被通知I/O就绪,它就应该尽可能多地执行I/O(例如尽可能多地读取字节)。假设文件描述符是非阻塞式的,这表示需要在循环中执行I/O系统调用直到失败为止,此时错误码为EAGAIN或EWOULDBLOCK。
对于不同的文件类型,发送I/O就绪的时刻也不同:
- 对于终端和伪终端,当产生新的输入时会生成一个输入就绪信号;对于终端则没有输出就绪信号
- 对于管道和FIFO,读端在数据写入管道或者写端关闭会产生信号;而写端会在读端关闭或者对管道的读操作增加管道的空余空间大小时发送信号
- 对于数据包套接字,当输入数据报到达或者发生异步错误时会产生信号;对于流式套接字,在监听套接字接收新连接、TCP的
connect()
请求完成、套接字上接收到了新的输入、输出就绪、异步错误、对端关闭写连接或者完全关闭时会产生信号 - 由inotify文件描述符监视的其中一个文件上有事件发生时会产生信号
在需要同时检查大量文件描述符(比如数千个)的应用程序中,信号驱动I/O可以提供显著的性能优势。
epoll
Linux特有的epoll(event poll)API可以检查多个文件描述符上的I/O 就绪状态。epoll API 的主要优点如下。
- 当检查大量的文件描述符时,epoll的性能延展性比
select()
和poll()
高很多。 - epoll API既支持水平触发也支持边缘触发
- 性能表现上,epoll同信号驱动I/O相似。但是,epoll可以避免复杂的信号处理流程(比如信号队列溢出时的处理),同时灵活性高,可以指定我们希望检查的事件类型
epoll API主要包含下面三个系统调用:
1 |
|
epoll_create
用于创建一个新的epoll实例,其中参数size
指定了想要通过epoll实例检查的文件描述符个数。如果执行成功,则返回一个代表新创建epoll实例的文件描述符。当这个epoll实例不需要使用时,则可以使用close()
将其关闭。当所有与epoll 实例相关的文件描述符都被关闭时,实例被销毁,相关的资源都返还给系统。
epoll_ctl
能够修改epfd
所代表的epoll实例中的兴趣列表。参数fd
表示要修改兴趣列表中的哪一个文件描述符的设定,该参数可以是代表管道、FIFO、套接字、POSIX消息队列、inotify实例、终端、设备,甚至是另一个epoll实例的文件描述符,但是不能为普通文件或者目录的文件描述符。参数op
用来指定需要执行的操作,可以是EPOLL_CTL_ADD、EPOLL_CTL_MOD或EPOLL_CTL_DEL,分别代表添加、修改和删除。参数ev
的定义如下:
1 | struct epoll_event{ |
epoll_wait
返回epoll实例中处于就绪态的文件描述符信息,单次调用就可以返回多个就绪态文件描述符的信息。参数evlist
指向的结构体数组中返回的是有关就绪态文件描述符的信息,它的空间需要由调用者负责申请,所包含的元素个数在参数maxevents
中指定。在数组evlist
中,每个元素返回的都是单个就绪态文件描述符的信息,events
字段返回了在该描述符上已经发生的事件掩码,data
字段返回的是在描述符上使用epoll_ctl()
注册感兴趣的事件时,在ev.data
中设置的值,它是唯一可以获知与这个事件相关文件描述符号的途径,因此通常将ev.data.fd
置为文件描述符,或者是将ev.data.ptr
设为指向包含文件描述符的结构体。timeout
参数决定了epoll
的阻塞行为,如果它的值为-1则会一直阻塞直到兴趣列表的描述符有一个达到就绪态;如果为0则不会阻塞,仅仅是检查是否有处于就绪态的文件描述符;如果大于0则代表阻塞的时间上限(毫秒)。
在epoll_event
结构体中,events
字段上的可以使用的位掩码值如下:
随着被监视的文件描述符数量的上升,poll()
和select()
的性能表现越来越差。与之相反,epoll的性能表现几乎不会降低。这是因为当通过epoll_ctl()
指定了需要监视的文件描述符时,内核会在与打开的文件描述上下文相关联的列表中记录该描述符。之后每当执行I/O操作使得文件描述符成为就绪态时,内核就在epoll描述符的就绪列表中添加一个元素。之后的epoll_wait()
调用从就绪列表中简单地取出这些元素。同时,在 epoll中我们使用epoll_ctl()
在内核空间中建立一个数据结构,该数据结构会将待监视的文件描述符都记录下来。一旦这个数据结构建立完成,稍后每次调用epoll_wait()
时就不需要再传递任何与文件描述符有关的信息给内核了,而调用返回的信息中只包含那些已经处于就绪态的描述符。