"> "> Linux-系统编程接口(摘录) | Yufei Luo's Blog

Linux-系统编程接口(摘录)

注:本文的内容摘录自《Linux/UNIX系统编程手册》(原名The Linux Programming Interface)

系统调用

系统调用是受控的内核入口,借助于这一机制,进程可以请求内核去执行某些动作。内核以应用程序编程接口(API)的形式,提供了一系列的服务供程序访问,包括创建新进程,执行I/O等。

执行系统调用时会完成如下这些步骤:

  1. 应用程序调用C语言函数库的外壳(wrapper)函数,来发起系统调用
  2. 外壳函数将系统调用参数放入到特定的寄存器中
  3. 外壳函数将系统调用编号复制到寄存器%eax
  4. 外壳函数执行中断机器指令(int 0x80),使处理器从用户态切换到内核态
  5. 内核调用system_call()例程:
    1. 在内核栈中保存寄存器的值
    2. 审核系统调用编号的有效性
    3. 以系统调用编号对存放所有调用服务例程的列表进行索引,发现并调用相应的系统调用服务例程,然后将结果状态返回给system_call()
    4. 从内核栈恢复各个寄存器的值,并将系统调用返回值置于栈中
    5. 返回至外壳函数,将处理器切换至用户态
  6. 如果系统调用的返回值表明调用有误,外壳函数会使用该值来设置全局变量errno,然后外壳函数返回调用程序,并同时返回一个整型值,表明系统调用是否成功。

系统限制和选项

概述

UNIX系统中需要对各种各样的系统特性和资源进行限制,并选择提供或者不提供由各种标准定义的选项,例如一个进程能同时拥有多少已打开的文件、路径名的最大长度、一个程序的参数列表可以多大等。在不同的操作系统实现中,这些变量往往不同。

系统限制

针对于系统中的每个限制,所有的实现都必须支持一个最小值,它们被定义为<limits.h>文件中的常量,命名形如_POSIX_XXX_MAX。这类常量的每一个都对应着对某类资源或者特性的上限,且要求这些上限具有一个确定的最小值。而在某些情况下,需要为某个限制提供一个最大值,对这些值的命名中包含字符串_MIN,它们代表了对某些资源的下限。

这些系统限制分为三类:

  1. 运行时恒定值:这些值可能依赖于具体的运行环境,需要在程序运行时调用sysconf()函数来获取。这一函数用法如下:

    1
    2
    #include<unistd.h>
    long sysconf(int name) //返回name指代的系统限制的数值,如果返回-1则代表这个系统限制未被确定或者发生错误
  2. 路径名变量值:与路径名(文件、目录、终端等)相关的限制,每个限制可能是相对于某个系统实现的常量,也可能随文件系统的不同而不同。在限制可能因路径名而发生变化的情况下,应用程序可以使用pathconf()fpathconf()来获取该值。它们的用法如下:

    1
    2
    3
    #include<unistd.h>
    long pathconf(const char* pathname, int name) //返回name指代的系统限制的数值,如果返回-1则代表这个系统限制未被确定或者发生错误
    long fpathconf(int fd, int name) //返回name指代的系统限制的数值,如果返回-1则代表这个系统限制未被确定或者发生错误
  3. 运行时可增加值:对于某些系统限制,特定系统在运行时可能会增加该值,应用程序可以使用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节点表内维护了一组指针,如下图所示:

image-20210628151559251

其中,每个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
2
3
4
5
#include<sys/mount.h>
int mount(const char* source, const char* target, const char* fstype, unsigned long mountflags, const void* data) //其中source指定要挂载的文件系统,target代表挂载的目录,fstype标识文件系统的类型,mountflags为掩码,用于修改mount操作,data为一个指向信息缓冲区的指针,对其信息的解释取决于文件系统。
int umount(const char* target) //target指定待卸载文件系统的挂载点
int umount2(const char* target, int flags) //加入flags参数,用于对卸载操作进行控制
//上述三个函数的返回值0代表成功,-1代表失败

要获得已挂载文件系统的相关信息,可以使用下面两个系统调用:

1
2
3
4
#include<sys/statvfs.h>
int statvfs(const char* pathname, struct statvfs* statvfsbuf)
int fstatvfs(int fd, struct statvfs* statvfsbuf)
//返回0代表成功,-1代表失败

上述调用同时会得到一个statvfs结构,属于由statvfsbuf指向的缓冲区。其中,statvfs数据结构包含了关于文件系统的信息。

Linux系统下的三个文件包含了当前已挂载或者可挂载的文件系统信息:

  • /proc/mounts:它是内核数据接口的接口,因此总是包含已挂载文件系统的精确信息
  • /etc/mtab:包含的内容与/proc/mounts类似,但是更加详细一些。
  • /etc/fstab:由系统管理员手动维护,包含了对系统支持的所有文件系统的描述

这三个文件的格式相同,都包含6个字段:已挂载设备名、设备挂载点、文件系统类型、挂载标志、用于控制对文件系统备份操作的数字、用于控制对文件系统检查顺序的数字。

虚拟内存文件系统

Linux同样支持驻留在内存中的虚拟文件系统tmpfs。对于应用程序来说,可以和使用其他文件系统一样的方法进行操作,但是由于不涉及磁盘访问,因此虚拟文件系统的操作速度极快。

要创建一个tmpfs文件系统,可以使用如下命令:mount -t tmpfs source target

目录与链接

硬链接

在文件系统中,目录的存储方式与普通文件类似,但是它们有两点不同:

  1. 在i-node条目中,目录会被标记为一种不同的文件类型
  2. 目录是经过特殊组织而成的文件,本质上是一个表格,其中包含了文件名和i-node编号

文件i-node中所存储的信息列表中并未包含文件名,仅通过目录列表内的一个映射来定义文件名称。通过这种方式,能够在相同或者不同目录中创建多个名称,每个均指向相同的i-node节点。这些名称被称为链接,有时也被称为硬链接。

在shell中,可以使用ln命令为一个已存在的文件创建新的硬链接,这样也就相当于同一个文件可以拥有多个名字。此时,如果移除其中一个文件名,另一个文件名以及文件本身将继续存在,但是会将文件i-node的链接计数减1。只有当文件的所有名字都被删除之后(即链接计数变为0),才会释放文件的i-node记录和数据块。

对硬链接有如下限制:

  1. 硬链接需要与其所指代的文件驻留在同一个文件系统中
  2. 不能为目录创建硬链接,否则将会导致出现链接环路

软链接

而软链接(符号链接)是一种特殊的文件类型,它的数据是另一文件的名称。在shell中,通过使用ln -s即可创建一个符号链接,ls -F命令的输出结果会在符号链接的尾部标记@。符号链接的内容可以是绝对或者相对路径,解释相对路径时将以链接本身的位置作为参照点。

需要注意的是,文件的链接计数中并未计算符号链接。因此,如果移除了符号链接所指向的文件名,符号链接还会继续存在。此时这一链接就变成了悬空链接,无法再对其进行解引用操作。也因此可以为并不存在的文件名创建一个符号链接。

由于符号链接指代一个文件名,因此它可以链接不同文件系统内的文件,也可以为目录创建符号链接。

符号链接直接可能会形成链路,在某些系统调用中如果指定了符号链接,内核会对一系列链接去层层解引用,直到最终文件。需要注意的是,有些系统调用会对符号链接进行解引用,而有些系统调用则对符号链接不做任何处理,直接作用于链接文件本身。

链接的系统调用

创建和移除链接

系统调用linkunlink可以被用于创建和移除硬链接,用法如下:

1
2
3
4
#include<unistd.h>
int link(const char* oldpath, const char* newpath)
int unlink(const char* pathname)
//返回0代表成功,-1代表失败

link系统调用中,如果oldpath提供的是一个硬链接,那么将以newpath参数指定的路径名创建一个新的链接,如果newpath指定的路径名存在,则产生错误。需要注意的是,link不会对符号链接进行解引用操作。

unlink系统调用则移除一个链接,且如果此链接是指向文件的最后一个链接,则还会移除文件本身。unlink不能用于移除目录,也不会对符号链接进行解引用操作。

Linux内核除了为每个i-node维护链接计数,还会为文件已打开的文件描述符计数。因此,当移除指向文件的最后一个链接时,如果仍有进程持有指代该文件的打开文件描述符,则在关闭所有的这类描述符之前,系统实际上不会删除该文件。这将允许在取消对文件链接的时候,无需担心是否有其它进程已经将其打开。

更改文件名

rename系统调用可以用于重命名文件,或者是将文件移动到同一文件系统中的另一目录:

1
2
#include<stdio.h>
int rename(const char* oldpath, const char* newpath) //成功返回0,失败返回-1

这一调用将现有路径名oldpath重命名为newpath参数指定的路径名。该操作仅操作目录条目,而不移动文件数据。改名既不影响指向该文件的其它硬链接,也不影响持有该文件打开描述符的任何进程。

rename满足下面的规则:

  • 如果newpath已经存在,则将其覆盖
  • 如果newpatholdpath指向同一文件,则不发生变化
  • 两个参数中的符号链接都不解引用
  • 如果oldpath指代文件,则不能将newpath指定为一个目录的路径名;而如果oldpath为目录名,则需要保证newpath不存在或者是空目录的名称,且newpath不能包含oldpath作为其目录前缀,此时相当于对目录重命名
  • 两个参数所指代的文件需要位于同一文件系统

使用符号链接

创建符号链接的系统调用如下:

1
2
#include<unistd.h>
int symlink(const char* filepath, const char* linkpath) //成功返回0,失败返回-1

symlink系统调用会针对于filepath指定的路径名创建一个新的符号链接linkpath。如果linkpath给定的路径名已经存在,则调用失败;而filepath可以为绝对或者相对路径,且它所命名的文件或者目录在调用时无需存在。

而获取符号链接本身的内容可以用如下系统调用:

1
2
#include<unistd.h>
ssize_t readlink(const char* pathname, char* buffer, size_t bufsiz) //成功则返回实际放入buffer的字节数,失败返回-1

这一系统调用会对pathname进行解引用,将其所指向的路径名称放入buffer指向的字符数组中,而bufsiz则对应于buffer参数的可用字节数。

文件/目录的创建和移除

mkdir系统调用用于创建一个新的目录:

1
2
#include<sys/stat.h>
int mkdir(const char* pathname, mode_t mode) //返回0表示成功,-1表示失败

pathname参数指定了新目录的路径名称,可为绝对路径或者相对路径;而mode参数指定了新目录的权限。在新建目录中包含两个条目...,分别代表指向目录自身的链接和指向父目录的链接。

需要注意的是,这一系统调用所创建的仅仅是路径名中的最后一部分,pathname参数中的父目录必须存在,这一函数才能执行成功。

如果要删除目录则可以使用如下的系统调用:

1
2
#include<unistd.h>
int rmdir(const char* pathname) //返回0表示成功,-1表示失败

pathname可以为绝对路径也可以为相对路径。要使得rmdir调用成功,则必须保证pathname对应的目录为空。如果pathname为符号链接,则不会对其做解引用操作,并返回错误。

要移除文件或者空目录也可以用remove库函数:

1
2
#include<stdio.h>
int remove(const char* pathname) //返回0表示成功,-1表示失败

如果pathname是一个文件,那么remove会调用unlink;而如果pathname是目录,则调用的是rmdirremove不对符号链接进行解引用操作,如果它是符号链接,则remove会移除链接本身,而不是链接指向的文件。

读目录

下面两个函数用于打开一个目录,并返回指向该目录的句柄,供后续调用使用。

1
2
3
4
#include<dirent.h>
DIR* opendir(const char* dirpath)
DIR* fdopendir(int fd)
//如果调用成功则返回文件目录流,失败则返回一个空指针

函数的返回结果是一个DIR类型的指针,这一结构即为目录流。函数在返回时会将目录流指向目录列表的首条记录。在调用fdopendir之后,文件描述符将处于系统的控制之下,除了使用个别函数,程序不应该采取任何方式对其进行访问。

对于得到的目录流,可以使用readdir函数从中读取条目:

1
2
#include<dirent.h>
struct dirent* readdir(DIR* dirp) //调用成功则返回一个dirent类型数据结构,如果失败或者DIR位于目录流的末尾,则返回空指针

每调用readdir一次,就会从dirp所指代的目录流中读取下一个目录条目,并返回一个指向静态分配dirent数据结构的指针,其中包含了目录条目的信息。每次调用都会覆盖dirent结构。

readdir函数的一个变体是readdir_r,这一函数是可重入的,用法为:

1
2
#include<dirent.h>
int readdir_r(DIR* dirp, struct dirent* entry, struct dirent** result) //成功返回0,失败则返回一个代表失败类型的正数

这一函数会将下一项目录条目放在entry指向的dirent结构中,同时会在result放置指向该结构的指针。

rewinddir()函数可以用于将目录流移动到起点:

1
2
#include<dirent.h>
void rewinddir(DIR* dirp)

closedir()函数可以将打开状态的目录流关闭:

1
2
#include<dirent.h>
int closedir(DIR* dirp) //成功返回0,失败返回-1

一个目录流会与一个文件描述符相关联,dirfd()函数返回与dirp目录流相关联的文件描述符:

1
2
#include<dirent.h>
int dirfd(DIR* dirp) //成功则返回相应的文件描述符,失败返回-1

而如果要递归遍历整个目录子树,可以使用nftw()函数:

1
2
3
4
5
6
#include<ftw.h>
int nftw(const char* dirpath
int (*func)(const char* pathname, const struct stat* statbuf, int typeflag, struct FTW* ftwbuf),
int nopenfd,
int flags)
//如果成功遍历则返回0,失败则返回-1或者是func返回的第一个非0值

默认情况下,nftw会针对于给定的树执行未排序的前序遍历,即对于各个目录的处理要比各目录下的文件和子目录优先。其中dirpath代表要遍历的目录树,func代表对目录树的每个文件所调用的函数,nopenfd代表可使用文件描述符数量的最大值,flags参数可以对函数的操作进行修正。

进程当前工作目录

一个进程的当前工作目录定义了该进程解析相对路径名的起点。新进程的当前工作目录继承自父进程。

要获取当前工作目录可以用getcwd命令:

1
2
#include<unistd.h>
char* getcwd(char* cwdbuf, size_t size) //如果成功则返回cwdbuf,失败返回NULL

这一函数会将内含当前目录绝对路径的字符串放置在cwdbuf指向的已分配缓冲区中,调用者需要为cwdbuf缓冲区分配至少size个字节的空间。一旦调用成功,getcwd将会返回一枚指向cwdbuf的指针。如果当前工作目录的路径名长度超过size,则会返回NULL,并将errno设置为ERANGE

如果cwdbuf为NULL且size为0,那么glibc封装函数会为getcwd按需分配一个缓冲区,并将指向该缓冲区的指针作为函数的返回值。

要改变当前工作目录有两种方法:

1
2
3
4
#include<unistd.h>
int chdir(const char* pathname)
int fchdir(int fd)
//返回0代表成功,-1代表失败

chdir系统调用将调用进程的当前工作目录改为由pathname指定的相对或者绝对路径名称,如果是符号链接还会解引用;而fchdir则是在指定目录时使用文件描述符,这一描述符为使用open打开相应目录时获得的。

改变进程的根目录

每个进程都有一个根目录,该目录是解释绝对路径(即以/开始的目录)时的起点。默认情况下,根目录为文件系统的真实根目录。有些场合需要改变一个进程的根目录,而特权级进程可以通过chroot系统调用来修改:

1
2
#include<unistd.h>
int chroot(const char* pathname) //成功返回0,失败返回-1

chroot会将进程根目录改为pathname指定的目录,如果它为符号链接则要对其解引用。

路径解析

realpath库函数可以用来解除路径中的符号链接,并解析其中对/./..的引用,从而生成一个以空字符结尾的字符串,内含相应的绝对路径名:

1
2
#include<stdlib.h>
char* realpath(const char* pathname, char* resolved_path) //如果成功则返回指向resolved_path的指针,失败返回NULL

glibc的realpath实现允许将resolved_path设置为空,此时函数会为解析生成的路径名称分配一个缓冲区,并将指向该缓冲区的指针作为结果返回。

dirnamebasename两个函数可以将一个路径名字符串分解成目录和文件名两部分:

1
2
3
#include<libgen.h>
char* dirname(char* pathname) //返回目录名
char* basename(char* pathname) //返回文件名

二者返回的字符串拼接起来,即可得到一个完整的路径名。

监控文件事件

概述

某些应用程序需要对文件或目录进行监控,已侦测其是否发生了特定事件。例如,当把文件加入或移出一目录时,图形化文件管理器应能判定此目录是否在其当前显示之列,而守护进程可能也想要监控自己的配置文件,以了解其是否被修改。Linux提供了inotify机制,以允许应用程序监控文件事件。使用inotify API由如下几个关键步骤:

  1. 使用inotify_init()创建一个inotify实例,这一调用会返回一个文件描述符,用于在后续操作中指向该实例
  2. 应用程序使用inotify_add_watch()向inotify实例的监控列表添加条目,告知内核哪些文件是自己的兴趣所在。每个监控项包含一个路径名,以及一个相关的位掩码,指明所要监控的事件集合。
  3. 为了获得事件通知,应用程序需要针对inotify文件描述符执行read()操作,每次对read()的成功调用,都会返回一个或者多个inotify_event结构,其中各自记录了处于inotify实例监控之下的某个路径名所发生的事件
  4. 在结束监控时,应用程序关闭inotify文件描述符,这样便会自动清除与inotify实例相关的所有监控项。

inotify机制不仅可以用于文件,还可以用于目录,监控目录时,与路径自身及其所含文件相关的事件都会通知给应用程序。

API

与inotify相关的API调用包括:

1
2
3
4
#include<sys/inotify.h>
int inotify_init(void) //成功则返回一个文件描述符,失败返回-1
int inotify_add_watch(int fd, const char* pathname, uint32_t mask) //成功则会返回一个监控描述符(wd),失败返回-1
int inotify_rm_watch(int fd, uint32_t wd) //成功返回0,失败返回-1

在使用inotify_add_watch时,位掩码参数mask标识了针对给定路径名而要监控的事件。

将监控项在监控列表中登记之后,应用程序可以使用read从inotify文件描述符中读取事件,以判定发生了哪些事件。如果直到读取时尚未发生任何事件,read调用会阻塞下去,直到有事件产生(如果设置了非阻塞的文件描述符标志,则会报错)

事件发生后,每次调用read都会返回一个缓冲区,其中包含一个或者多个inotify_event的数据结构。这一数据结构的定义如下:

1
2
3
4
5
6
7
struct inotify_event{
int wd; //监控描述符
uint32_t mask; //描述事件类型的位掩码
uint32_t cookie; //用于将相关的事件联系在一起
uint32_t len; //name字段的长度
char name[]; //以空字符填充的文件名
}

文件加锁

flock函数

flock系统调用在整个文件上放置一个锁。待加锁的文件是一个通过传入fd的一个打开着的文件描述符:

1
2
3
#include<sys/file.h>

int flock(int fd, int operation); //返回0表示成功,-1表示失败

operation参数为LOCK_SH(给文件加共享锁)、LOCK_EX(给文件加互斥锁)和LOCK_UN(解锁fd引用的文件)。如果要进行非阻塞操作,则可以使用或操作加上LOCK_NB选项。

任意数量的进程可同时持有一个文件上的共享锁,但在同一个时刻只有一个进程能够持有一个文件上的互斥锁。

fcntl函数

使用fcntl()能够在一个文件的任意部分上放置一把锁,这个文件部分既可以是一个字节,也可以是整个文件。一般来讲,fcntl()会被用来锁住文件中与应用程序定义的记录边界对应的字节范围,这也是术语记录加锁的由来。Linux系统可以将一个记录锁应用在任意类型的文件描述符上。

用来创建或者删除一个文件锁的fcntl调用的形式如下:

1
2
3
4
5
6
7
8
9
10
struct flock flockstr;
fcntl(fd, cmd, &flockstr);

struct flock{
short l_type; //表示锁的类型,可以为F_RDLCK(放置读锁)、F_WRLCK(放置写锁)、F_UNLCK(删除锁)其中之一
short l_whence; //表示l_start参数如何使用,可以设为SEEK_SET、SEEK_CUR和SEEK_END
off_t l_start; //锁的起始位置(相对于l_whence的位置)
off_t l_len; //加锁的字节长度。它可以设置为0,代表对l_start和l_whence确定的起始位置到文件结尾的所有字节全部加锁
pid_t l_pid; //如果cmd为F_GETLK,那么代表持有锁的进程ID
}

其中,cmd参数可以为F_SETLK(获取或者释放flockstr指定的字节上的锁,如果另一个进程持有与待加锁区域任意部分不兼容的锁则返回EAGAIN错误)、F_SETLKW(获取或释放锁,但是如果遇到待加锁区域不兼容的情况则会阻塞等待)、F_GETLK(检测释放可以在给定区域上锁)这三个值的其中一个。

文件I/O

概述

在Linux系统中一切皆可以看成是文件,文件又可分为:普通文件、目录文件、字符设备文件、块设备文件、套接字文件、管道文件和链接文件。文件描述符(file descriptor)是内核为了高效管理已被打开的文件所创建的索引,其是一个非负整数(通常是小整数),用于指代被打开的文件,所有执行I/O操作的系统调用都通过文件描述符。一个程序刚刚启动的时候,0是标准输入,1是标准输出,2是标准错误。如果此时去打开一个新的文件,它的文件描述符会是3。

在Linux系统中,为了维护文件描述符,建立了三个表:进程级的文件描述符表(每个进程维护一个)、系统级的文件描述符表(所有进程共享)和文件系统的i-node表(所有进程共享),它们的关系如下图所示:

image-20210624093356351

每一个文件描述符会与一个打开文件相对应。同时,不同的文件描述符也可以指向同一个文件。相同的文件可以被不同的进程打开,也可以在同一个进程中被多次打开。这些文件描述符也可以被重定向,从而指向其它任何文件对象。

大多数程序使用的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
2
3
#include <sys/stat.h>
#include <fcntl.h>
int open(const char* pathname, int flags, mode_t mode)

如果打开成功,则返回一个文件描述符,用于在后续的函数调用中指代该文件;如果发生错误,则返回-1,并将errno设置为相应的错误标志。而且这一函数调用会保证,如果调用成功,则返回值是进程未使用的文件描述符中数值最小者。

其中参数的含义如下:

  • pathname:要打开的文件
  • flags:位掩码,用于指定文件的访问模式,可以用一系列的常量进行位或运算进行组合,例如O_RDWR|O_CREAT|O_TRUNC
  • mode:如果使用open()创建新文件,则这一参数可以用于指定文件的访问权限;如果未指定O_CREAT标志,则可以省略该参数

对于flags位掩码,它们可以使用的常量分为如下几组:

  • 文件访问模式标志:O_RDONLYO_WRONLYO_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()调用失败,并返回错误,错误号errnoEEXIST。也就是说,此标志确保了调用者(open( )的调用进程)就是创建文件的进程。对文件是否存在的检查和创建文件属于同一原子操作。
    • O_LARGEFILE:支持以大文件方式打开文件。由于存放文件偏移量的数据类型off_t是一个有符号的长整型数,因此在32位系统下,文件大小的限制为2GB以下。如果在32位系统下要处理大文件,则需要使用这一标志。此外,也可以在每个头文件中加入#define _FILE_OFFSET_BITS 64来实现。
    • O_NOATIME:在读文件时,不更新文件的最近访问时间。要使用该标志,要么调用进程的有效用户ID必须与文件的拥有者相匹配,要么进程需要拥有特权(CAP_FOWNER)。否则,open()调用失败,并返回错误,错误号errnoEPERM
    • O_NOCTTY:如果正在打开的文件属于终端设备,这一标志防止其成为控制终端
    • O_NOFOLLOW:在open()函数中指定了O_NOFOLLOW标志,且pathname参数属于符号链接,则open()函数将返回失败(错误号errnoELOOP)。此标志在特权程序中极为有用,能够确保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
2
#include <unistd.h>
ssize_t read(int fd, void *buffer, size_t count)

其中count参数指定最多能够读取的字节数,buffer参数提供用来存放输入数据的内存缓冲区地址,缓冲区应该至少有count个字节。

如果read()调用成功,则返回实际读取的字节数;如果遇到文件结束则返回0;如果出现错误则返回-1。

write()

write()系统调用将数据写入一个已经打开的文件中,它的用法如下:

1
2
#include <unistd.h>
ssize_t write(int fd, void *buffer, size_t count)

fd为待写入文件的描述符,count参数指定最多能够写入文件的字节数,buffer参数为要写入文件中数据的内存缓冲区地址,缓冲区应该至少有count个字节。

如果函数调用成功,将返回实际写入文件的字节数。该返回值可能小于count,对于磁盘文件可能因为磁盘已满,或者进程资源对于文件大小的限制。

需要注意的是,对磁盘文件执行I/O操作时,write()调用成功并不能保证数据已经写入磁盘。这是因为为了减少磁盘活动量和加快write()系统调用,内核会缓存磁盘的I/O操作。

close()

close()系统调用关闭一个打开的文件描述符,并将其释放回调用进程。这一文件描述符可以供该进程后续分配给其它文件。当一个进程终止时,将自动关闭它打开的所有文件描述符。它的用法为:

1
2
#include <unistd.h>
int close(int fd)

如果关闭成功,则返回0;失败则返回-1。

由于文件描述符属于有限资源,因此在程序中显式关闭不再需要的文件描述符是一个好的编程习惯,使得代码更具可读性也更加可靠。

lseek()

对于每个打开的文件,系统内核会记录一个文件偏移量(又被称为读写偏移量或指针)。文件偏移量指的是下一个read()或者write()操作的文件起始位置,以相对于文件头部起始点的位置来表示。文件第一个字节的偏移量为0。

打开一个文件时,会将文件偏移量设置为指向文件开始,以后每次read()或者write()调用都将自动对其进行调整,以指向已读或者已写数据的下一字节。

它的使用方法如下:

1
2
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence)

如果设置成功,则返回新的偏移位置,失败则返回-1。其中,fd为文件描述符,指向一个已打开文件;offset参数指定了一个以字节为单位的数值,它是一个有符号的整型数;whence参数表明参照哪个基点来解释offset参数。

whence参数可以为下列之一:

  • SEEK_SET:将文件偏移量设置为从文件头部起始点开始的offset个字节。此时offset必须为非负数。
  • SEEK_CUR:相对于当前的文件偏移量,将文件偏移量调整offset个字节。此时offset可以为正也可以为负。
  • SEEK_END:将文件偏移量设置为起始于文件尾部的offset个字节,也就是从文件最后一个字节之后的下一个字节算起。此时offset可以为正也可以为负。

如果程序的文件偏移量已跨越了文件结尾,然后再执行I/O操作,则会造成文件空洞。从文件结尾后到新写入数据间的这段空间被称为文件空洞。从编程角度看,文件空洞中是存在字节的,读取空洞将返回以0(空字节)填充的缓冲区。然而,文件空洞不占用任何磁盘空间。直到后续某个时点,在文件空洞中写入了数据,文件系统才会为之分配磁盘块。文件空洞的主要优势在于,与为实际需要的空字节分配磁盘块相比,稀疏填充的文件会占用较少的磁盘空间。

特殊用法

fcntl()

fcntl()系统调用对一个打开的文件描述符执行一系列的控制操作,它的用法如下:

1
2
#include<fcntl.h>
int fcntl(int fd, int cmd, ……)

其中,fd指的是文件描述符,cmd用于设置这一函数的功能,而后面的省略号表示参数会根据cmd的具体类型而定。返回值为-1表示失败,如果成功的话,返回值会因cmd而异。下面为fcntl的一些使用示例。

例1:可以使用fcntl()来获取一个打开文件的访问模式和状态标志。方法如下:

1
int flags=fcntl(fd, F_GETFL);

后续可以使用flags参数通过位运算来检查一些状态标志,例如:

1
2
3
flags & O_SYNC //测试文件是否以同步写方式打开
accessMode = flags & O_ACCMODE //借助掩码O_ACCMODE,获取文件的访问模式
accessMode == O_WRONLY //通过与常量进行比对,来判断访问模式的种类

例2:同样地,也可以修改一个打开文件的某些状态标志,允许更改的标志有:O_APPENDO_NONBLOCKO_NOATIMEO_ASYNCO_DIRECT。修改方式如下:

1
2
3
int new_flags;
//do something to set new_flags
fcntl(fd, F_SETFL, new_flags)

例3:可以用fcntl()复制文件描述符,用法为:

1
int newfd = fcntl(oldfd, F_DUPFD, startfd);

其中,oldfd指的是要复制的文件描述符,startfd为文件描述符的副本进行编号限制,将使用不小于startfd的最小未用值作为描述符的编号。

此外,另一系统调用dup3也可以完成这一功能,用法如下:

1
2
#include<unistd.h>
int dup3(int oldfd, int newfd, int flags);//flags只支持O_CLOEXEC标志,fcntl中可以使用F_DUPFD_CLOEXEC标志完成同样功能

如果成功则返回新的文件描述符,失败返回-1。

特定偏移量的I/O

系统调用pread()pwrite()可以完成与read()write()相类似的工作,但是前两者会在offset参数所指定的位置进行操作,而不是从当前的文件偏移量处,且不改变文件的当前偏移量。用法如下:

1
2
3
#include<unistd.h>
ssize_t pread(int fd, void* buf, size_t count, off_t offset); //如果成功则返回读取的字节数,0代表文件末尾,-1代表失败
ssize_t pwrite(int fd, const void* buf, size_t count, off_t offset); //如果成功则返回写入的字节数,-1代表失败

分散输入和集中输出

系统调用readv()writev()实现了分散输入和集中输出的功能,用法如下:

1
2
3
#include<sys/uio.h>
ssize_t readv(int fd, const struct iovec* iov, int iovcnt); //如果成功则返回读取的字节数,0代表文件末尾,-1代表失败
ssize_t writev(int fd, const struct iovec* iov, int iovcnt); //如果成功则返回写入的字节数,-1代表失败

上述两个系统调用一次可传输多个缓冲区的数据,数组iov定义了一组用来传输数据的缓冲区,iovcnt定义了iov数组的长度。iovec的数据结构如下:

1
2
3
4
struct iovec{
void* iov_base;
size_t iov_len;
}

readv()从文件描述符中读取一片连续的字节,然后将其分散放置在iov指定的缓冲区中,从第一个缓冲区开始,依次填满所有的缓冲区。如果数据不足以填充所有缓冲区,则只会占有部分。

writev()iov指定的所有缓冲区的内容拼接起来,然后以连续字节序列写入文件描述符指代的文件中。

如果想在指定的文件偏移量处执行分散输入/集中输出,则可以使用下面两个函数:

1
2
3
#include<sys/uio.h>
ssize_t preadv(int fd, const struct iovec *iov, int iovcnt, off_t offset); //如果成功则返回读取的字节数,0代表文件末尾,-1代表失败
ssize_t pwritev(int fd, const struct iovec *iov, int iovcnt, off_t offset); //如果成功则返回写入的字节数,-1代表失败

文件截断

下列两个系统调用可以将文件大小设置为length参数指定的值:

1
2
3
#include<unistd.h>
int truncate(const char* pathname, off_t length);
int ftruncate(int fd, off_t length);

如果成功,则函数的返回值为0;失败则返回-1。当文件长度大于length时,调用会丢弃超出部分,但如果小于参数length,则调用将会在文件尾部添加一系列空字节或者一个文件空洞。

对于truncate()函数而言,它需要以路径名字符串来指定文件,并要求文件可访问,且对文件具有写权限。如果文件名为符号连接,则会对其解引用;而调用ftruncate()函数之前,需要以可写方式打开文件,获取其文件描述符,这一系统调用不会修改文件的偏移量。

/dev/fd目录

对于每个进程,内核都提供了一个特殊的虚拟目录/dev/fd,该目录中包含/dev/fd/n形式的文件名,其中n是与进程中的打开文件相对应的编号。打开/dev/fd目录中的一个文件就等同于复制相应的文件描述符,因此下面两行代码等价:

1
2
fd=open("/dev/fd/1",O_WRONLY);
fd=dup(1);

需要注意的是,如果使用open()函数打开文件,则需要将其设置为与原描述符相同的访问模式,此时如果在flag标志的设置中引入其它标志是无意义的,系统会自动忽略。

创建临时文件

有些程序需要创建一些临时文件,仅供其在运行期间使用,程序终止后立即删除。有两个函数可以用于创建临时文件:

1
2
#include<stdlib.h>
int mkstemp(char *template)

如果成功则返回一个文件描述符,失败则返回-1。其中参数template采用路径名的形式,最后6个字符必须为XXXXXX,这6个字符将会被替换,以保证文件名的唯一性,而且修改后的字符串将会被保存到template参数中。因此,template参数被设置为字符数组,而不是字符串常量。

1
2
#include<stdio.h>
FILE* tmpfile(void)

tmpfile()函数会创建一个名称唯一的临时文件,并以读写方式打开。这一函数执行成功之后,将会返回一个文件流供stdio库函数使用。文件流关闭之后将自动删除临时文件。

文件I/O缓冲

内核缓冲

read()write()系统调用在操作磁盘文件时并不会直接发起磁盘访问,而是在用户空间缓冲区与内核缓冲区高速缓存之间复制数据。因此,写操作会先将用户空间内存传递到内核空间的缓冲区,然后在后续某个时刻再将缓冲区的数据刷写到磁盘中。如果在此期间另一进程试图读取文件的数据,内核将自动从缓冲区而不是文件中读取;而对于读操作,内核从磁盘中读取数据并存储到内核缓冲区,然后从缓冲区读取数据,直到把缓冲区的数据读完。对于序列化的文件访问,内核通常会执行预读以加快读取速度。

如果与文件发生大量的数据传输,通过采用大块空间缓冲数据,以及执行更少的系统调用,可以极大地提高I/O性能。

有时,我们需要控制文件I/O内核缓冲。fsync系统调用将使得缓冲数据与打开文件描述符fd相关的所有元数据都刷新到磁盘上。调用这一函数会强制使得文件处于同步I/O完成的状态,此时读请求的文件数据已经从磁盘传递给了进程,而写请求所指定的数据已经传递给磁盘,且用于获取数据的所有文件元数据以及所有发生更新的文件元数据也已经传递完毕。函数调用方式如下:

1
2
#include<unistd.h>
int fsync(int fd) //成功则返回0,失败返回-1

这一函数只有在对磁盘设备的传递完成之后才会返回。

另一个系统调用是fdatasync,它的运作类似于fsync,但是对于文件的元数据来说,只要求获取数据的所有文件元数据传递完毕:

1
2
#include<unistd.h>
int fdatasync(int fd) //成功则返回0,失败返回-1

fdatasync可能会减少磁盘操作的次数,因为在这一函数的调用过程中,部分元数据的改变无需进行更新。而fsync则会强制将元数据也传递到磁盘上。对于某些对性能要求较高,但是对某些元数据准确性要求不高的应用,便可以通过这种方式减少磁盘操作次数。

sync系统调用则会使包含更新文件信息的所有内核缓冲区(数据块、指针块、元数据等)刷新到磁盘上:

1
2
#include<unistd.h>
void sync(void)

在调用open函数时,指定O_SYNC标志也会使得后续每次调用write时都会自动将文件数据和元数据刷新到磁盘上。

需要特别注意的是,采用O_SYNC标志(或者频繁调用fsync()fdatasync()sync())对性能的影响极大,会使得写入速度大大增加,尤其是当缓冲区的大小较低的时候。因此,如果需要强制刷新内核缓冲区,在设计应用程序的时候就应该考虑是否可以使用大尺寸的write()缓冲区。

stdio库的缓冲

C语言的stdio函数库可以避免自行处理对数据的缓冲,一些相关的函数调用如下。

设置缓冲模式
1
2
#include<stdio.h>
int setvbuf(FILE* stream, char* buf, int mode, size_t size) //成功则返回0,失败返回非0值

在打开一个文件流之后,setvbuf函数必须在调用任何其他stdio函数之前调用。这一调用将会影响后续在指定流上的所有stdio操作。其中各个参数的含义如下:

  • stream:指定要修改的文件流
  • buf:针对于参数stream要使用的缓冲区,如果值为NULL,那么stdio库会为其自动分配一个缓冲区;如果不为NULL,则使用指向size大小的内存块作为缓冲区
  • mode:指定缓冲类型,具有下列值之一:
    • _IONBF:不对I/O进行缓冲,每个stdio库函数立即调用write()read()函数,并忽略bufsize参数。stderr默认为这一类型
    • _IOLBF:采用行缓冲I/O,指代终端设备的流默认属于这一类型。对于输出流,在输出一个换行符之前将缓冲数据;而对于输入流则每次读取一行数据
    • _IOFBF:采用全缓冲I/O,单次读写数据的大小与缓冲区相同。指代磁盘的流默认采用这一方式

setbuf函数构建于setvbuf之上,执行了类似任务:

1
2
#include<stdio.h>
void setbuf(FILE* stream, char* buf)

setbuf函数中,参数buf可以被设置为NULL表示无缓冲,也可以被设置为指向由调用者分配的BUFSIZ个字节大小的缓冲区。BUFSIZ参数定义于<stdio.h>头文件中。

setbuffer函数类似于setbuf函数,但是允许调用者指定buf缓冲区大小:

1
2
#include<stdio.h>
void setbuffer(FILE* stream, char* buf, size_t size)
刷新stdio缓冲区

无论当前采用哪一种缓冲区模式,在任何时候都可以使用fflush库函数强制将stdio输出流中的数据刷新到内核缓冲区中,用法如下:

1
2
#include<stdio.h>
int fflush(FILE* stream) //返回0代表成功,EOF代表错误

如果参数streamNULL,则fflush函数将刷新所有的stdio缓冲区。也可以将这一函数应用于输入流,这将丢弃已经缓冲的输入数据。

stdio库与系统调用混合

在同一文件上执行I/O操作时,还可以将系统调用和标准C语言库函数混合使用,下面两个函数有助于完成这一工作:

1
2
3
#include<stdio.h>
int fileno(FILE* stream) //成功则返回文件描述符,失败返回-1
FILE *fdopen(int fd, const char* mode) //成功则返回一个新的文件指针,失败返回NULL

二者的功能相反,fileno函数是给定一个文件流,然后返回相应的文件描述符,之后便可在I/O系统调用中正常使用该文件描述符;而fdopen则给定一个文件描述符,然后创建一个使用该描述符进行文件I/O的相应流,mode参数与fopen中的含义相同,如果该参数与fd的访问模式不一致则会失败。

fdopen函数对于非常规的文件描述符很有用,借助这一函数便可在套接字、管道等文件类型上使用stdio库函数。

总结

stdio函数库和内核所采用的缓冲机制可以总结为下图:

image-20210628135556878

文件属性

获取文件信息

使用如下几个系统调用,可以获取与文件有关的信息,其中大部分都提取自文件的i节点:

1
2
3
4
5
#include<sys/stat.h>
int stat(const char* pathname, struct stat* statbuf) //返回所命名文件的相关信息
int lstat(const char* pathname, struct stat* statbuf) //与stat类似,但是如果文件是符号连接则返回符号连接自身
int fstat(int fd, struct stat* statbuf) //返回由某个文件描述符所指代文件的信息
//对于上述调用,0表示成功,-1表示失败

对于statlstat,无需对其所操作的文件本身拥有任何权限,但是对于指定路径的父目录要有执行(搜索)权限。

上述调用都会在缓冲区中返回一个由statbuf指向的stat结构,这个结构中包含了设备ID、i节点号、文件所有权、文件类型及权限、文件大小、已分配块、文件时间戳等信息。

文件时间戳

stat结构的st_atimest_mtimest_ctime字段为文件的时间戳,分别记录了文件的上次访问时间、上次修改时间、文件状态(即i节点内的信息)上次发生变更的时间。对时间戳的记录形式为自1970年1月1日以来经历的秒数。

一些系统调用可以用来修改时间戳:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include<utime.h>
int utime(const char* pathname, const struct utimbuf* buf) //可以改变存储于文件i节点中的文件上次访问时间戳和上次修改时间戳,pathname代表要修改时间的文件,buf可以为NULL,也可以为指向utimbuf结构的指针。如果buf为NULL,则将两个时间同时改为当前时间;如果为指向utimbuf结构的指针,则按照结构内相应字段的值去更新时间。
struct utimbuf{
time_t actime; //访问时间
time_t modtime; //修改时间
}

#include<sys/time.h>
int utimes(const char* pathname, const struct timeval tv[2]) //允许微秒级精度指定时间值
int futimes(int fd, const struct timeval tv[2]) //使用文件描述符fd来指定文件
int lutimes(const char* pathname, const struct timeval tv[2]) //使用路径名指定文件,如果指向链接则不进行解引用

#include<sys/stat.h>
int utimensat(int dirfd, const char* pathname, const struct timespec times[2], int flags) //支持纳秒精度的时间戳,同时可以独立设置某一个时间戳,flags参数可以为0或者AT_SYMLINK_NOFOLLOW,表示对符号链接不做解引用
int futimens(int fd, const struct timespec times[2])
//上述函数返回0表示成功,-1表示失败

文件所有权

每个文件都有一个与之管理的用户ID和组ID,据此可以判定文件所属的用户和组。当一个文件被创建时,其用户ID取进程的有效用户ID,而组ID则曲子进程的有效组ID或父目录的组ID。

下面的系统调用可以修改文件所有权:

1
2
3
4
5
#include<unistd.h>
int chown(const char* pathname, uid_t owner, gid_t group) //改变pathname参数对应文件的所有权。owner和group代表新的用户ID和组ID,如果无需改变某一个则将其设置为-1
int lchown(const char* pathname, uid_t owner, gid_t group) //与chown相同,但是pathname如果为符号连接,则改变链接文件本身的所有权
int fchown(int fd, uid_t owner, gid_t group) //修改fd对应文件的所有权
//上述函数返回0表示成功,-1表示失败

文件权限

对于普通文件,stat结构中的st_mod字段低12位定义了文件权限,其中前3位为专用位,分别为set-user-ID位、set-group-ID位和sticky位,其余9位构成了定义权限的掩码,分别授予访问文件的各类用户(文件所有者、文件所属组、其它用户)的权限(可以读取文件内容、可以更改文件内容、可以执行文件)。

头文件<sys/stat.h>包含了用来表示文件权限位的常量,如下图所示:

image-20210629110659209

备注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来进行权限检查。检查文件权限时,内核所遵循的规则如下:

  1. 对于特权级进程,授予其所有访问权限。
  2. 若进程的有效用户ID与文件的用户ID(属主)相同,内核会根据文件的属主权限,授予进程相应的访问权限。比方说,若文件权限掩码中的属主读权限(owner-read permission)位被置位,则授予进程读权限。否则,则拒绝进程对文件的读取操作。
  3. 若进程的有效组ID或任一附属组ID与文件的组ID属组)相匹配,内核会根据文件的属组权限,授予进程对文件的相应访问权限。
  4. 若以上三点皆不满足,内核会根据文件的other(其他)权限,授予进程相应权限。

而系统调用access便可以根据当前进程的真实用户ID和组ID来检查文件的访问权限:

1
2
#include<unistd.h>
int access(const char* pathname, int mode) //如果mode中的所有权限被允许则返回0,否则返回-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
2
#include<sys/stat.h>
mode_t umask(mode_t mask)

对这一函数的调用总会成功,并返回进程的前一个umask

而下面两个系统调用可以直接修改文件的权限:

1
2
3
4
#include<sys/stat.h>
int chmod(const char* pathname, mode_t mode)
int fchmod(int fd, mode_t mode)
//返回0代表成功,-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名均自成一体。在usertrusted两个命名空间中,EA名可以为任意字符串;而在system内,只有经内核明确认可的命名才可以使用。

在shell中,可以使用命令setfattrgetfattr来设置和查看文件的EA。同时,也有一系列的系统调用可以用来对文件的EA进行操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include<sys/xattr.h>
//创建和修改EA,返回0代表成功,-1代表失败
int setxattr(const char* pathname, const char* name, const void* value, size_t size, int flags)
int lsetxattr(const char* pathname, const char* name, const void* value, size_t size, int flags)
int fsetxattr(int fd, const char* name, const void* value, size_t size, int flags)
//获取EA的值,如果成功则返回EA值的大小,失败返回-1。其中value指向一个缓冲区,size代表缓冲区大小
ssize_t getxattr(const char* pathname, const char* name, void* value, size_t size)
ssize_t lgetxattr(const char* pathname, const char* name, void* value, size_t size)
ssize_t fgetxattr(int fd, const char* name, void* value, size_t size)
//删除EA,返回0代表成功,-1代表失败
int removexattr(const char* pathname, const char* name)
int lremovexattr(const char* pathname, const char* name)
int fremovexattr(int fd, const char* name)
//获取与文件相关联的所有EA的名称,如果成功则返回list所包含的字节数,失败则返回-1。list指向一个缓冲区,size表示缓冲区大小
ssize_t listxattr(const char* pathname, char* list, size_t size)
ssize_t llistxattr(const char* pathname, char* list, size_t size)
ssize_t flistxattr(int fd, char* list, size_t size)

虚拟内存

内存映射

概述

内存映射分为文件映射和匿名映射。文件映射将一个文件的一部分直接映射到调用进程的虚拟内存中。一旦一个文件被映射之后,就可以通过在相应的内存区域中操作字节来访问文件内容。映射的分页会在需要的时候从文件中(自动)加载。这种映射也被称为基于文件的映射或内存映射文件。而对于匿名映射来说,一个匿名映射没有对应的文件。相反,这种映射的分页会被初始化为 0。

一个进程映射中的内存可以与其他进程中的映射共享(即各个进程的页表条目指向RAM中的相同分页)。当两个或更多个进程共享相同分页时,每个进程都有可能会看到其他进程对分页内容做出的变更,这取决于映射是私有的还是共享的。对于私有映射来说,在映射内容上发生的变更对其它进程不可见;而对于共享映射来说,在映射内容上发生的变更对所有共享同一个映射的其他进程都可见。

这两种映射特性组成了四种不同的内存映射:

  • 私有文件映射:映射的内容被初始化为一个文件区域中的内容。这种映射的主要用途是使用一个文件的内容来初始化一块内存区域,例如根据二进制可执行文件的相应部分来初始化一个进程的文本和数据段。
  • 私有匿名映射:每次调用mmap()创建一个私有匿名映射时都会产生一个新映射,该映射与同一(或不同)进程创建的其他匿名映射是不同的,它们不会共享物理分页。
  • 共享文件映射:所有映射一个文件的同一区域的进程会共享同样的内存物理分页,这些分页的内容将被初始化为该文件区域。对映射内容的修改将直接在文件中进行。这种映射方式允许内存映射I/O,同时允许无关进程共享一块内容以便进行进程间通信。
  • 共享匿名映射:每次调用mmap()创建一个共享匿名映射时都会产生一个新的、与任何其他映射不共享分页的新映射。映射的分页不会被写时复制,并且一个进程对映射内容所做出的变更会对其他进程可见。共享匿名映射允许以一种类似于System V 共享内存段的方式来进行IPC,但仅限于相关进程之间。

创建映射

mmap()系统调用在调用进程的虚拟地址空间中创建一个新的映射:

1
2
3
#include<sys/mman.h>

void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset); //成功则返回内存映射的起始地址,失败返回MAP_FAILED

addr参数指定了映射被放置的虚拟地址,可以指定为NULL使内核为映射选择一个合适的地址。length参数指定了映射的字节数,会被向上提升为分页大小的整数倍。prot为一个位掩码,取值可以为PROT_NONE,或者PROT_READ、PROT_WRITE和PROT_EXEC的组合。flags参数是一个控制映射操作各个方面选项的位掩码,可以为MAP_PRIVATE和MAP_SHARED之一,也可以同时用或操作包含其它值,如MAP_ANONYMOUS、MAP_FIXED、MAP_LOCKED等。fdoffset用于文件映射,fd参数为被映射文件的文件描述符,offset参数指定映射在文件中的起点,必须是系统分页大小的倍数。

解除映射

munmap()系统调用执行的是与mmap()相反的操作,即从调用进程的虚拟地址空间中删除一个映射:

1
2
3
#include<sys/mman.h>

int munmap(void* addr, size_t length); //成功返回0,失败返回-1

addr参数是待解除映射的地址范围的起始地址,它必须与一个分页边界对齐。length是一个非负整数,它指定了待解除映射区域的大小。在解除映射时,可以解除整个映射,也可以解除一个映射中的一部分。

当一个进程终止或执行了一个exec()之后进程中所有的映射会自动被解除。

文件映射

要创建一个文件映射需要执行下面的步骤。

  1. 获取文件的一个描述符,通常通过调用open()来完成。
  2. 将文件描述符作为 fd参数传入mmap()调用。

执行上述步骤之后,mmap()会将打开的文件的内容映射到调用进程的地址空间中。一旦mmap()被调用之后,就能够关闭文件描述符,而不会对映射产生任何影响。

匿名映射

在Linux中,使用mmap()创建匿名映射有两种办法。一种是在flags指定MAP_ANONYMOUS 并将fd设置为-1,另一种是打开/dev/zero设备文件,并将得到的文件描述符传递给mmap()。此时,得到的映射中的字节会被初始化为0,offset 参数会被忽略。

同步映射区域

内核会自动将发生在MAP_SHARED映射内容上的变更写入到底层文件中,但在默认情况下,内核不保证这种同步操作会在何时发生。msync()系统调用让应用程序能够显式地控制何时完成共享映射与映射文件之间的同步:

1
2
3
#include<sys/mman.h>

int msync(void* addr, size_t length, int flags); //返回0表示成功,-1表示失败

addrlength参数指定了需同步的内存区域的起始地址和大小。在addr中指定的地址必须是分页对齐的,length会被向上舍入到系统分页大小的下一个整数倍。flags参数可以为MS_SYNC(会阻塞直到内存区域中所有被修改过的分页被写入到硬盘为止)、MS_ASYNC(内存区域仅与内核高速缓冲区同步)和MS_INVALIDATE(使映射数据的缓存副本失效)其中之一。

重新映射

Linux提供了mremap()系统调用,使得映射的位置和大小可以被改变:

1
2
#include<sys/mman.h>
void* mremap(void* old_address, size_t old_size, size_t new_size, int flags, ……) //成功则返回新映射地址的起始处,失败返回MAP_FAILED

old_addressold_size参数指定了需扩展或收缩的既有映射的位置和大小。在old_address中指定的地址必须是分页对齐的,并且通常是一个由之前的mmap()调用返回的值。映射预期的新大小会通过new_size参数指定。在old_sizenew_size中指定的值都会被向上舍入到系统分页大小的下一个整数倍。

在执行重映射的过程中内核可能会为映射在进程的虚拟地址空间中重新指定一个位置,而是否允许这种行为则是由flags参数来控制的。它是一个位掩码,其值要么是0,要么为MREMAP_MAYMOVE或者MREMAP_MAYMOVE|MREMAP_FIXED(此时需要额外传入一个参数void* new_address,将映射迁移至这个指定地址)

虚拟内存操作

改变内存保护

mprotect()系统调用修改起始位置为addr(必须是分页大小整数倍),长度为length(会被向上舍入到分页大小整数倍)字节的虚拟内存区域中对分页的保护:

1
2
3
#include<sys/mman.h>

int mprotect(void* addr, size_t length, int prot); //返回0表示成功,-1表示失败

prot参数是一个位掩码,它指定了这块内存区域上的新保护,其取值是PROT_NONE或PROT_READ、PROT_WRITE以及PROT_EXEC这三个值中的一个或多个取或操作。如果一个进程在访问一块内存区域时违背了内存保护,那么内核就会向该进程发送一个SIGSEGV信号。

内存锁

在一些应用程序中,将一个进程的虚拟内存的部分或全部锁进内存以确保它们总是位于物理内存中,可以提高程序的性能,并且保证敏感数据不会被交换到磁盘上。

特权进程能够锁住的内存数量是没有限制的(即RLIMIT_MEMLOCK会被忽略);非特权进程能够锁住的内存数量上限由软限制RLIMIT_MEMLOCK定义。由于虚拟内存的管理单位是分页,因此内存加锁会应用于整个分页。在执行限制检查时,RLIMIT_MEMLOCK限制会被向下舍入到最近的系统分页大小的整数倍。

对内存区域加锁和解锁的操作如下:

1
2
3
4
5
#include<sys/mman.h>

int mlock(void* addr, size_t length); //addr为起始地址,不要求分页对齐,如果未对其则强制移动到下一个分页边界;由于加锁操作的单位是分页,因此被锁住的区域的结束位置为大于length加addr的下一个分页边界
int munlock(void* addr, size_t length); //参数含义与mlock相同。不会立即删除分页,只有其它进程请求内存时才会从RAM中删除分页
//返回0表示成功,-1表示失败

除了显式地使用munlock()之外,内存锁在下列情况下会被自动删除:

  • 进程终止
  • 被锁住的分页通过munmap被解除映射
  • 被锁住的分页通过mmapMAP_FIXED标记的映射覆盖时

一个进程可以给它占据的所有内存加锁和解锁:

1
2
3
4
5
#include<sys/mman.h>

int mlockall(int flags);
int munlockall(void);
//返回0表示成功,-1表示失败

其中flags参数可以为MCL_CURRENT、MCL_FUTURE以及二者的或。MCL_CURRENT将调用进程虚拟地址空间中当前所有映射的分页锁进内存,MCL_FUTURE将后续映射进调用进程的虚拟地址空间的所有分页锁进内存。

确定内存驻留性

mincore()系统调用是内存加锁系统调用的补充,它报告在一个虚拟地址范围中哪些分页当前驻留在RAM 中,因此在访问这些分页时也不会导致分页故障:

1
2
3
#include<sys/mman.h>

int mincore(void* addr, size_t length, unsigned char* vec); //返回0表示成功,-1表示失败

这一系统调用返回起始地址为addr,长度为length字节的虚拟地址范围中分页的内存驻留信息。addr中的地址必须是分页对齐的,并且由于返回的信息是有关整个分页的,因此length实际上会被向上舍入到系统分页大小的下一个整数倍。内存驻留的相关信息会通过vec返回。

建议后续的内存使用模式

madvise()系统调用通过通知内核调用进程对起始地址为addr长度为length字节的范围之内分页的可能的使用情况,来提升应用程序的性能。内核可能会使用这种信息来提升在分页之下的文件映射上执行的I/O的效率。

1
2
3
#include<sys/mman.h>

int madvise(void* addr, size_t length, int advice); //返回0表示成功,-1表示失败

参数addr的值要求分页对齐,length会被向上舍入到系统分页大小的下一个整数倍,advice可以为下面几个值其中之一:

  • MADV_NORMAL:默认行为,分页以簇(一个系统分页大小整数倍)传输,这样会导致一些预先读和事后读
  • MADV_RANDOM:这个区域的分页会被随机访问,这样预先读不会带来任何好处
  • MADV_SEQUENTIAL:在此范围内的分页只会被访问一次,并且是顺序访问
  • MADV_WILLNEED:预先读取这个区域中的分页以备将来的访问之需
  • MADV_DONTNEED:调用进程不再要求这个区域中的分页驻留在内存中

定时器

间隔定时器

系统调用setitimer会创建一个间隔式定时器,它会在未来的某个时间点到期,(可选)并于此后每隔一段时间到期一次:

1
2
#include<sys/time.h>
int setitimer(int which, const struct itimerval* new_value, struct itimerval* old_value) //返回0代表成功,-1代表失败

which参数可以设置为下面3个值的其中一个:

  • ITIMER_REAL:创建以真实时间倒计时的定时器,到期时产生SIGALARM信号
  • ITIMER_VIRTUAL:创建以进程虚拟时间(用户模式下的CPU时间)倒计时的定时器,到期产生SIGVTALRM信号
  • ITIMER_PROF:创建一个profiling定时器,以进程时间(用户态与内核态CPU时间总和)倒计时,到期产生SIGPROF信号

对上述所有这些信号的默认处置均会终止进程。

参数new_valueold_value均为指向结构itimerval的指针,其定义如下:

1
2
3
4
5
6
7
8
struct itimerval{
struct timeval it_interval;
struct timeval it_value;
}
struct timeval{
time_t tv_sec; //秒数
suseconds_t tv_usec; //微秒
}

一个进程只能拥有上述三类计时器的各一个。当第二次调用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
2
#include<sys/time.h>
int getitimer(int which, const struct itimerval* curr_value) //返回0代表成功,-1代表失败

一个更加简单的定时器接口是alarm()系统调用:

1
2
#include<unistd.h>
unsigned int alarm(unsigned int seconds);

其中,参数seconds表示定时器到期的秒数。到期时会向调用进程发送SIGALRM信号。调用alarm会覆盖对定时器的前一个设置,而调用alarm(0)可屏蔽现有定时器。它的返回值是定时器的前一个设置距离到期的剩余秒数,如果未设置定时器则返回0。

取决于当前负载和对进程的调度,系统可能会在定时器到期的瞬间(通常是几分之一秒)之后才去调度其所属进程。但是后续定时器的调度会严格遵守其设置的时间间隔。而且定时器的精度受制于软件时钟的频率,如果定时器值未能与软件时钟间隔的倍数严格匹配,那么定时器值则会向上取整。

休眠

有时需要将进程挂起一段时间,此时可以使用休眠函数来实现:

1
2
3
4
5
6
7
8
9
#include<unistd.h>
unsigned int sleep(unsigned int seconds)//休眠正常结束则返回0,如果因信号而中断则返回剩余秒数

#include<time.h>
int nanosleep(const struct timespec* request, struct timespec* remain) //返回0代表休眠正常结束,-1代表因信号而中断或者错误。request参数用于设置休眠时间,remain函数为返回的时间
struct timespec{
time_t tv_sec;
long tv_nsec;
}

POSIX时钟

POSIX时钟所提供的时钟访问API可以支持纳秒级的时间精度,在Linux中调用此API的程序必须以-lrt选项进行编译,从而与librt函数库相链接。POSIX时钟API的三个主要系统调用如下:

1
2
3
4
5
6
7
#include<time.h>
int clock_gettime(clockid_t clockid, struct timespec* tp) //针对于clockid指定的时钟返回时间,返回0表示成功,-1表示失败
int clock_getres(clockid_t clockid, struct timespec* res) //针对于clockid指定的时钟返回时钟分辨率,返回0表示成功,-1表示失败
int clock_settime(clockid_t clockid, const struct timespec* tp) //利用参数tp所指向缓冲区中的时间来设置clockid指定的时钟,返回0表示成功,-1表示失败
int clock_getcpuclockid(pid_t pid, clockid_t* clockid) //获得进程pid的clockid,如果pid为0则返回调用进程的CPU时间时钟ID;clockid指向一个缓冲区。返回0代表成功,失败则返回一个对应于错误类型的正数
int pthread_getcpuclockid(pthread_t thread, clockid_t* clockid) //获取线程thread的clockid。返回0代表成功,失败则返回一个对应于错误类型的正数
int clock_nanosleep(clockid_t clockid, int flags, const struct timespec* request, struct timespec* remain)

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
2
3
4
5
6
7
8
#include<signal.h>
#include<time.h>

int timer_create(clockid_t clockid, struct sigevent* evp, timer_t* timerid) //创建一个定时器,使用clockid指定的时钟来进行时间度量。evp决定定时器到期时对应用程序的通知方式,timerid为一个缓冲区。
int timer_settime(timer_t timerid, int flags, const struct itimerspec* value, struct itimerspec* old_value) //启动/停止定时器,timerid由timer_create()调用返回。flags可以为0,代表相对时间;或者为TIMER_ABSTIME,代表绝对时间
int timer_gettime(timer_t timerid, struct itimerspec* curr_value) //返回由timerid指定POSIX定时器的间隔和剩余时间
int timer_delete(timer_t timerid) //删除一个定时器
//上述调用成功返回0,失败返回-1

文件描述符定时器

Linux特有的timerfd API可以从文件描述符中读取其所创建定时器的到期通知。相关的系统调用包括:

1
2
3
4
5
#include<sys/timerfd.h>
int timerfd_create(int clockid, int flags) //创建一个新的定时器对象,并返回一个指代该对象的文件描述符。参数clockid 的值可以设置为CLOCK_REALTIME或CLOCK_MONOTONIC; flags参数可设为TFD_CLOEXEC或TFD_NONBLOCK
int timerfd_settime(int fd, int flags, const struct itimerspec* value, struct itimerspec* old_value) //启动/停止定时器
int timerfd_gettime(int fd, struct itimerspec* curr_value) //返回fd标识定时器的间隔和剩余时间
//上述调用成功返回0,失败返回-1

一旦以timerfd_settime()启动了定时器,就可以从相应文件描述符中调用read()来读取定时器的到期信息。出于这一目的,传给read()的缓冲区必须足以容纳一个无符号8字节整型(uint64_t)数。在上次使用timerfd_settime()修改设置以后,或是最后一次执行read()后,如果发生了一起到多起定时器到期事件,那么read()会立即返回,且返回的缓冲区中包含了已发生的到期次数。如果并无定时器到期,read()会一直阻塞直至产生下一个到期。

可以利用select()poll()epoll()timerfd文件描述符进行监控。如果定时器到期,会将对应的文件描述符标记为可读。

进程

基础内容

进程号

每个进程都有一个进程号(PID),它是一个正数,用来唯一标识系统中的某个进程。对于各种系统调用而言,进程号有时候可以作为传入参数,有时候可以作为返回值。

系统调用getpid返回调用进程的进程号:

1
2
#include<unistd.h>
pid_t getpid(void);

getpid()返回值的数据类型是pid_t,这一数据类型被专门用来存储进程号。创建一个新的进程时,内核会按顺序将下一个可用的进程号分配给新进程使用;而当进程号达到最大值的限制时,内核将重置进程号计数器至300(因为低数值的进程号通常被系统进程和守护进程长期占用,故之间跳过这一区域)。Linux的最大进程号由内核常量PID_MAX所定义。

每个进程都有一个创建自己的父进程,系统调用getppid可以检索父进程的进程号:

1
2
#include<unistd.h>
pid_t getppid(void);

所有进程的始祖是1号进程init,在Linux的命令行输入pstree 1即可看到系统中进程的家族树结构。

进程的内存布局

在Linux系统中,进程的内存被布局到虚拟内存中。每个进程所分配的虚拟内存由很多部分组成,每个部分称为“段”,如下图所示:

image-20210625111723877

每个部分的含义如下:

  • 文本段:包含进程运行的程序机器语言指令。为了防止进程通过错误指针修改指令,这段内存被设置为只读;同时因为多个进程可以运行同一程序,故这一段内存可共享,这样便可将程序代码映射到多个进程的虚拟地址空间中。
  • 初始化数据段:包含显式初始化的全局变量和静态变量。当程序加载到内存时,从可执行文件中读取这些变量的值。
  • 未初始化数据段:包含未进行显式初始化的全局变量和静态变量,程序启动之前,这段内存的所有值被初始化为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来访问环境列表。environargv参数类似,指向一个以NULL结尾的指针数组,而其它每个指针又指向一个以空字节终止的字符串。

如果要在环境列表里面检索单个值,可以用下面的函数:

1
2
#include<stdlib.h>
char* getenv(const char* name)

这一函数接收一个环境变量名称name作为参数,返回相应的value,并以字符串指针的形式传值;如果不存在指定名称的环境变量则返回NULL。

如果要修改环境,可以使用的函数包括:

1
2
3
4
5
#include<stdlib.h>
int putenv(char* string) //用于添加一个新变量或者修改一个已经存在的变量值,string参数指向一个name=value格式的字符串。如果成功则返回0,失败返回一个非0值
int setenv(const char* name, const char* value, int overwrite) //用于添加一个新变量或者修改一个已经存在的变量值,如果overwrite参数为0则不会改变已有的环境变量,如果不为0则总是改变环境。如果成功返回0,失败返回-1
int unsetenv(const char* name) //从环境中移除name标识的环境变量,如果成功返回0,失败返回-1
int clearenv(void) //清空整个环境,如果成功则返回0,失败返回一个非0值

非局部跳转

库函数setjmp()longjmp()可以执行非局部跳转,此处的非局部指的是跳转的目标位于当前执行函数之外的某个位置。这两个函数可以被用于下面的场景:在一个深层嵌套的函数调用中发生了错误,需要放弃当前任务,从多层函数调用中返回主函数。二者的用法如下:

1
2
3
#include<setjmp.h>
int setjmp(jmp_buf env);
void longjmp(jmp_buf env, int val);

setjmp函数用于设置跳转点,在后面调用longjmp时就会跳转到setjmp的位置。从编程的角度来看,调用longjmp之后,看起来和第二次调用setjmp返回时完全一样。通过查看setjmp()返回的整数值,可以区分setjmp调用是初始返回还是第二次“返回”。初始调用返回值为0,后续“伪”返回的返回值为longjmp()调用中val参数所指定的任意值。通过对val参数使用不同值,能够区分出程序中跳转至同一目标的不同起跳位置。

env参数用于存储当前进程的信息,以及程序计数寄存器和栈指针寄存器的副本等信息,这些信息被用于恢复setjmp处的程序执行状态,从而使得后续的程序可以被接着执行。在调用longjmp时,需要传入与setjmp相同的env变量。由于两个函数的调用通常位于不同的函数,因此env参数常常被设置为全局变量。

在实际工程中,应该尽可能地避免使用setjmplongjmp这两个函数,因为它们会使得程序地复杂程度变高,使程序难以阅读和维护。

内存分配

在堆上分配内存

进程可以通过增加堆的大小来分配内存,通常将堆的当前内存边界称为“Program Break”。在程序开始运行的时候,Program Break位于未初始化数据段的末尾之后。而在Program Break的位置抬升之后,程序可以访问新分配区域内的任何内存地址,而此时物理内存页尚未分配。内核会在进程首次试图访问这些虚拟内存地址时自动分配新的物理内存页。

下面两个系统调用可以操纵Program Break:

1
2
3
#include<unistd.h>
int brk(void* end_data_segment) //返回0代表成功,-1代表失败
void* sbrk(intptr_t increment) //调用成功则返回上一个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
2
#include<stdlib.h>
void* malloc(size_t size)

如果函数执行成功,则返回分配内存起始位置的指针,失败则返回NULL。由于函数的返回类型为void*,因此可以将其赋值给任意类型的指针。同时malloc返回的内存块已经基于8或者16字节进行内存对齐,从而适宜于高效访问不同类型的数据结构。

malloc()的实现很简单。它首先会扫描之前由free()所释放的空闲内存块列表,以求找到尺寸大于或等于要求的一块空闲内存。(取决于具体实现,采用的扫描策略会有所不同)如果这一内存块的尺寸正好与要求相当,就把它直接返回给调用者。如果是一块较大的内存,那么将对其进行分割,在将一块大小相当的内存返回给调用者的同时,把较小的那块空闲内存块保留在空闲列表中。

如果在空闲内存列表中根本找不到足够大的空闲内存块,那么malloc()会调用sbrk()以分配更多的内存。为减少对sbrk()的调用次数,malloc()并未只是严格按所需字节数来分配内存,而是以更大幅度(以虚拟内存页大小的数倍)来增加Program Break,并将超出部分置于空闲内存列表。

堆内存的释放可以使用free函数:

1
2
#include<stdlib.h>
void free(void* ptr)

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
2
#include<alloca.h>
void* alloca(size_t size)

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
2
#include<sys/utsname.h>
int uname(struct utsname* utsbuf)

其中,utsbuf参数是一个指向utsname的指针,它的定义如下:

1
2
3
4
5
6
7
8
9
10
struct utsname{
char sysname[_UTSNAME_LENGTH]; //由内核自动设置
char nodename[_UTSNAME_LENGTH]; //由sethostname()系统调用设置
char release[_UTSNAME_LENGTH]; //由内核自动设置
char version[_UTSNAME_LENGTH]; //由内核自动设置
char machine[_UTSNAME_LENGTH]; //由内核自动设置
#ifdef _GNU_SOURCE
char domainname[_UTSNAME_LENGTH]; //由setdomainname()系统调用设置
#endif
};

进程的创建

系统调用fork()可以创建一个新的进程,它的用法为:

1
2
#include<unistd.h>
pid_t fork(void);

这一系统调用执行完成之后,将会存在两个进程,这两个进程都会从fork()的返回处继续往下执行。这两个进程执行相同的程序代码段,但是各自拥有不同的栈段、数据段和堆段拷贝。子进程的栈、数据和堆段开始时完全复制于父进程。而在此之后,每个进程可以修改各自的栈数据和堆段的变量,对另一进程完全没有影响。

程序代码可以通过fork()的返回值来区分父进程和子进程。在父进程中,这一系统调用返回的时子进程的进程ID,而在子进程中则返回0。如果无法创建子进程,则父进程返回-1。

执行fork()之后,子进程会获得父进程所有文件描述符的副本。因此这意味着父、子进程中对应的文件描述符会指向相同的打开文件句柄(其中含有当前文件偏移量、文件状态标志,这些属性在父子进程中共享)。因此如果子进程更新了文件偏移量,那么这也会影响到父进程中相应的描述符。

在执行fork()之后,父进程和子进程执行的特定顺序是不确定的,如果要保证某一特定执行顺序,则需要使用一些同步技术如信号等。

进程的终止

API

进程的终止有两种方式,一种为异常终止,通过接收一个信号而引发;而另一个方式是使用_exit()系统调用正常终止:

1
2
#include<unistd.h>
void _exit(int status);

其中,status参数定义了进程的终止状态,父进程可以调用wait()来获取该状态。虽然它为int数据类型,但是只有低8位可以被父进程使用。调用_exit()的程序总是会成功终止。

但是程序一般不会直接调用_exit(),而是调用库函数exit(),它会在调用_exit()之前执行各种动作:

1
2
#include<stdlib.h>
void exit(int status);

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
2
3
4
#include<stdlib.h>
int atexit(void (*func)(void));
int on_exit(void (*func)(int, void*), void* args);
//返回0代表成功,非0值代表失败

可以注册多个退出处理程序,甚至将同一个函数注册多次。当应用程序调用exit()时,这些函数的执行顺序与注册顺序相反。但是如果其中任意一个退出处理程序无法返回,那么就不再调用剩余的处理程序。

通过fork()创建的子进程会继承父进程注册的退出处理函数。而进程调用exec()时,会移除所有已注册的退出处理程序。

进程监控

等待子进程

对于需要创建子进程的程序来说,父进程可以监测子进程的终止时间和过程是很有必要的。系统调用wait()等待调用进程的任意一个子进程终止,同时在参数status所指向的缓冲区中返回该子进程的终止状态:

1
2
#include<sys/wait.h>
pid_t wait(int* status) //如果成功则返回终止的进程ID号,失败返回-1

这一系统调用会执行如下的动作:

  1. 如果调用这一函数时没有任何子进程终止,则这一调用将一直阻塞,直到某个子进程终止;如果调用时已有子进程终止,则立即返回。需要注意的是,如果在同一时刻有多个子进程同时退出,wait处理它们的顺序没有任何的规定。
  2. 如果status非空,则关于子进程如何终止的信息会通过它指向的整型变量返回
  3. 内核将为父进程下所有子进程的运行总量追加进程CPU时间以及资源使用数据
  4. 将终止子进程的ID作为wait()的结果返回

出错时返回-1,可能的错误原因之一是调用进程并无已经终止的子进程,此时会将errno设置为ECHILD。

系统调用wait()存在很多限制,包括:

  • 如果父进程已经创建了多个子进程,无法等待某个特定子进程的完成,只能按顺序等待下一个子进程的终止
  • 如果没有子进程退出,则一直保持阻塞状态。有时会希望执行非阻塞的等待
  • 只能发现那些已经终止的子进程,无法处理子进程因为某个信号而停止或者已停止子进程收到信号恢复执行的情况

为了解决这些限制,可以使用waitpid()系统调用:

1
2
#include<sys/wait.h>
pid_t waitpid(pid_t pid, int* status, int options) //如果成功则返回子进程ID,失败则返回0或-1

waitpidwait的返回值和status参数的含义相同。参数pid表示需要等待的具体子进程,如果大于0则表示等待进程ID为pid的子进程,如果等于0则等待与父进程同一个进程组的所有子进程,如果小于-1则等待进程标识符等于pid绝对值的所有子进程,如果等于-1则等待任意子进程。参数options是一个位掩码,可以按位或操作包含这些标志:WUNTRACED(除返回终止子进程的信息外,还返回因信号而停止的子进程信息)、WCONTINUED(返回那些收到SIGCONT信号而恢复执行的已停止子进程的状态信息)、WNOHANG(如果参数pid指定的子进程并未发生状态改变,则立即返回而不会阻塞,此时返回0。

wait或者waitpid返回的status值可以用来区分不同的子进程事件。有一组标准宏可以用来解析等待状态值:

1
2
3
4
5
#include<sys/wait.h>
WIFEXITED(status) //如果子进程正常结束则返回真,使用宏WEXITSTATUS(status)可以进一步查看子进程的退出状态
WIFSIGNALED(status) //如果通过信号杀死子进程则返回真,使用宏WTERMSIG(status)返回导致子进程终止的信号编号
WIFSTOPPED(status) // 如果子进程因为信号而停止则返回真,使用WSTOPSIG(status)可以返回导致子进程停止的信号编号
WIFCONTINUED(status) //如果子进程收到SIGCONT而恢复执行,则返回真

在Linux系统中,也可以使用waitid系统调用来等待子进程:

1
2
3
#include<sys/wait.h>
int waitid(idtype_t idtype, id_t id, siginfo_t* infop, int options)
//返回-1代表失败,如果返回0则代表成功,或者是在设置WNOHANG标志下无子进程可以等待

其中,idtypeid指定了需要等待的子进程,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
2
#include<unistd.h>
int execve(const char* pathname, char* const argv[], char* const envp[]); //如果执行失败则返回-1,成功则永不返回

其中,参数pathname包含了准备载入当前进程空间新程序的路径名,可以为绝对或者相对路径;参数argv用来给程序传递命令行参数;最后一个参数envp指定了新程序的的环境列表。在调用execve之后,因为同一进程依然存在,所以进程ID保持不变。

如果函数返回则表明发生错误,可以从errno来判断原因。可能返回的错误有:EACCES,代表pathname指向的不是常规文件、文件不可执行或者其中某一级目录不可搜索;ENOENT,代表pathname指向的文件不存在;ENOEXEC,代表系统无法识别文件格式;ETXTBSY,代表存在进程以写入方式打开pathname指代的文件;E2BIG,代表参数列表和环境列表所需空间总和超出了允许的最大值。

基于execve系统调用,还有下面的多种库函数可以选择,它们在为新程序指定程序名、参数列表以及环境变量的方式上有所不同:

1
2
3
4
5
6
#include<unistd.h>
int execle(const char* pathname, const char* arg, …, char* NULL, char* const envp[])
int execlp(const char* filename, const char* arg, …, char* NULL)
int execvp(const char* filename, const char* argv[])
int execv(const char* pathname, const char* argv[])
int execl(const char* pathname, const char* arg, …, char* NULL)

这些函数的差异体现在函数名称在exec之后的不同后缀:

  • 后缀p代表系统会在由环境变量PATH所指定的目录列表中寻找相应的执行文件,允许只提供程序的文件名而不提供完整路径。如果文件名称中包含"/"则将其视为相对或者绝对路径名,不再使用变量PATH来搜索文件。
  • 后缀l代表以字符串列表的形式来指定参数,而不使用数组来描述argv列表。字符串列表需要以NULL指针来终止。
  • 后缀e代表允许手动为新程序指定环境变量,而其余函数则使用调用者当前环境作为新程序的环境

文件描述符与信号

默认情况下,由exec()的调用程序所打开的所有文件描述符在exec()的执行过程中会保持打开状态,且在新程序中依然有效。如果要改变这一设定,可以在打开文件描述符时设置FD_CLOEXEC标志。

exec()在执行时会将现有进程的文本段丢弃。该文本段可能包含了由调用进程创建的信号处理器程序。既然处理器已经不知所踪,内核就会将对所有已设信号的处置重置为SIG_DFL,而对所有其他信号(即将处置置为SIG_IGNSIG_DFL的信号)的处置则保持不变。

执行shell命令

程序可以通过调用system()函数来执行任意的shell命令,用法如下:

1
2
#include<stdlib.h>
int system(const char* command)

函数会创建一个子进程来运行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
2
#include<unistd.h>
pid_t getpgrp(void); //返回进程的组ID号,返回值与调用进程的进程 ID 匹配的话就说明该调用进程是其进程组的首进程。

修改进程组ID可以使用下面的函数:

1
2
#include<unistd.h>
int stdpgid(pid_t pid, pid_t pgid); //返回0为成功,-1为失败

如果pid的值为0,则调用进程的进程组ID就会被改变;如果pgid设置为0,那么ID为pid进程的进程组ID就会被设置为pid的值。在其它情况下,如果pidpgid参数指定同一个进程,那么就会创建一个新的进程组,且这个指定的进程为新组的首进程;如果两个参数指定不同的进程,那么会将一个进程从一个进程组移到另一个进程组。

控制终端保留了前台进程组的概念,前台进程组是唯一能够自由地读取和写入控制终端的进程组。在一个会话中,在同一时刻只有一个进程能成为前台进程,会话中的其他所有进程都是后台进程组。要获取或者修改一个终端的进程组可以使用下面的函数:

1
2
3
#include<unistd.h>
pid_t tcgetpgrp(int fd);
int tcsetpgrp(int fd, pid_t pgid);

会话

一个进程的会话成员关系是由其会话ID来定义的,会话ID是一个数字。新进程会继承其父进程的会话ID。要查看会话ID可以使用getsid()系统调用:

1
2
#include<unistd.h>
pid_t getsid(pid_t pid); //失败返回-1,成功返回指定进程的会话ID。如果pid为0则返回调用进程的会话ID

要创建一个新会话可以使用setsid()系统调用:

1
2
#include<unistd.h>
pid_t setsid(void); //成功则返回一个新会话的ID号,失败返回-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
2
3
#include<sys/resource.h>
int getpriority(int which, id_t who); //如果成功则返回nice值,失败返回-1
int setpriority(int which, id_t who, int prio); //成功返回0,失败返回-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
2
3
4
#include<sched.h>
int sched_get_priority_min(int policy);
int sched_get_priority_max(int policy);
//policy代表要查询的调度策略,一般为SCHED_FIFO和SCHED_RR。成功则返回相应的优先级数值,失败返回-1

要修改一个进程的调度策略和优先级的方法如下:

1
2
3
4
5
#include<sched.h>
int sched_setscheduler(pid_t pid, int policy, const struct sched_param* param);
struct sched_param{
int sched_priority;
}

通过fork()创建的子进程会继承父进程的调度策略和优先级,并且在exec()调用中会保持这些信息。

sched_setparam()系统调用提供了sched_setscheduler()函数的一个功能子集。它修改一个进程的调度策略,但不会修改其优先级:

1
2
#include<sched.t>
int sched_setparam(pid_t pid, const struct sched_param* param); //返回0表示成功,-1表示失败

要查看调度策略和优先级可以使用如下的系统调用:

1
2
3
#include<sched.h>
int sched_getscheduler(pid_t pid);
int sched_getparam(pid_t pid, struct sched_param* param);

实时进程如果要自愿释放CPU可以使用sched_yield系统调用:

1
2
#include<sched.h>
int sched_yield(void);

要查看SCHED_RR调度时间片的大小可以使用下面的系统调用:

1
2
#include<sched.h>
int sched_rr_get_interval(pid_t pid, struct timespec* tp);

CPU亲和力

当一个进程在一个多处理器系统上被重新调度时无需在上一次执行的CPU上运行。但是出于性能优化的考虑,有时需要为进程设置硬CPU亲和力,显式地将其限制在某一个或者一组CPU上运行。

要修改和获取进程的硬CPU亲和力可以使用下面的函数:

1
2
3
4
#include<sched.h>
int sched_setaffinity(pid_t pid, size_t len, cpu_set_t* set);
int sched_getaffinity(pid_t pid, size_t len, cpu_set_t* set);
//返回0表示成功,-1表示失败

cpu_set_t数据类型是一个位掩码,但是通常将其看作是不透明结构,对它的操作使用下面几个宏来完成:

1
2
3
4
5
6
7
#define _GNU_SOURCE
#include<sched.h>

void CPU_ZERO(cpu_set_t* set); //将set初始化为空
void CPU_SET(int cpu, cpu_set_t* set); //将cpu添加到set中
void CPU_CLR(int cpu, cpu_set_t* set); //将cpu从set中删除
int CPU_ISSET(int cpu, cpu_set_t* set); //检查cpu是否包含在set中

进程资源

进程资源使用

要查看调用进程或其子进程用掉的各类系统资源的统计信息,可以使用getrusage()系统调用:

1
2
#include<sys/resource.h>
int getrusage(int who, struct rusage* res_usage);

其中,who参数指定需要查询资源使用信息的进程,它的取值为下列的其中一个:

  • RUSAGE_SELF:返回调用进程的相关信息
  • RUSAGE_CHILDREN:返回调用进程的所有被终止和处于等待状态的子进程相关的信息。
  • RUSAGE_THREAD:返回调用线程相关的信息

res_usage参数是一个指向rusage结构的指针,其中存储了各类系统资源使用的详细信息。

进程资源限制

每个进程都用一组资源限值,它们可以用来限制进程能够消耗的各种系统资源。要查看或者修改资源限制,可以使用下面的系统调用:

1
2
3
4
5
6
7
#include<sys/resource.h>
int getrlimit(int resource, struct rlimit* rlim);
int setrlimit(int resource, const struct rlimit* rlim);
struct rlimit{
rlim_t rlim_cur;
rlim_t rlim_max;
}

其中,resource参数标识出了需读取或修改的资源限制,rlim参数用来返回限制值或指定新的资源限制值。resource参数可以使用的值包括:

image-20210714161535465

DAEMON进程

概述

daemon进程通常指的是在后台运行并且不拥有控制终端,且生命周期很长的进程。例如httpd、inetd等进程。

创建流程

要创建一个daemon进程,程序要完成下面的步骤:

  1. 执行fork(),然后父进程退出,子进程继续执行。这保证子进程可以一直在后台执行,且子进程不会成为一个进程组的首进程
  2. 子进程调用setsid(),开启一个新会话,并释放它与控制终端的所有关联关系
  3. 如果daemon 后面可能会打开一个终端设备,那么必须要采取措施来确保这个设备不会成为控制终端。例如使用O_NOCTTY标记,或者在setsid()之后调用执行第二个fork()并退出父进程
  4. 清除进程的umask,确保创建文件和目录时的所需权限
  5. 修改进程当前工作目录为根目录
  6. 关闭daemon从其父进程继承而来的所有打开着的文件描述符
  7. daemon 通常会打开/dev/null并使用dup2()(或类似的函数)使所有这些描述符指向这个设备

一些函数库提供了daemon()函数,可以将调用者变为一个daemon进程。

重新初始化

有时需要修改daemon的操作参数,或者让其对文件进行处理,因此需要为daemon进程设置一些重新初始化的方法。一种方案是让daemon为SIGHUP信号建立一个处理器,并且在收到此信号时采取措施。由于daemon没有控制终端,因此内核永远不会向daemon发送SIGHUP信号。这样daemon就可以借助这个信号达到目的。

syslog工具

由于daemon是在后台运行的,因此通常无法像其他程序那样将消息输出到关联终端上。这个问题的一种解决方式是将消息写入到一个特定于应用程序的日志文件中。syslog 工具提供了一个集中式日志工具,系统中的所有应用程序都可以使用这个工具来记录日志消息。

syslog API 由以下三个主要函数构成:

  • openlog:为后续的syslog调用建立默认设置
  • syslog:记录一条日志消息
  • closelog:完成日志消息记录之后,拆除与日志之间的连接

它们的使用方法为:

1
2
3
4
5
#include<syslog.h>
void openlog(const char* ident, int log_options, int facility); //ident时一个指向字符串的指针,syslog()输出的每一条消息都会包含这个字符串;log_options是一个位掩码,用于设置log的格式;facility参数表示记录日志消息的应用程序的类别,指定了后续的syslog()调用中所使用的默认facility值
void syslog(int priority, const char* format, ...); //priority参数是facility和level值的或运算结果,用于设置记录日志消息的应用程序以及消息的严重程度,另一个参数是一个格式字符串以及相应的参数
void closelog(void); //关闭日志
int setlogmask(int mask_priority); //设置一个可以过滤由syslog写入消息的掩码

/etc/syslog.conf配置文件控制syslogd daemon的操作,这个文件由规则和注释构成。通过这一文件,可以实现一些更加强大的规则,例如指定消息发送的位置、指定发送消息的类型等。

线程

概述

线程是允许应用程序并发执行多个人物的一种机制,一个进程可以包含多个线程。同一程序中的所有线程会独立执行相同区域,且共享同一份全局内存区域。因此,线程之间可以方便、快速地共享信息,同时创建线程的速度也比创建进程要快得多。

线程之间共享的属性包括:全局内存、进程ID和父进程ID、进程组ID和会话ID、控制终端、进程凭证、打开的文件描述符、记录锁、信号处置、文件系统的相关信息、间隔定时器和POSIX定时器、System V信号量撤销值、资源限制、CPU时间消耗、资源消耗、nice值

各线程独有的属性包括:线程ID、信号掩码、线程特有数据、备选信号栈、errno变量、浮点型环境、实时调度策略和优先级、CPU亲和力、Linux特有的Capability、栈,本地变量和函数的调用链接信息。

创建与终止

启动程序时,产生的进程只有单条线程,被称为初始或者主线程。函数pthread_create()负责创建一条新的线程:

1
2
3
#include<pthread.h>
int pthread_create(pthread_t* thread, const pthread_attr_t* attr, void*(*start)(void *), void* arg)
//执行成功返回0,识别返回一个正数代表错误码

新线程通过调用带有参数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
2
#include<pthread.h>
void pthread_exit(void* retval)

执行这一函数相当于在线程的start函数中执行return,但是这一函数可以在线程start函数所调用的任意函数中被调用。参数retval指定了线程的返回值,它所指向的内容不应被分配到线程栈中,因为线程终止之后无法确定线程栈中的内容是否有效。

线程的连接与分离

默认情况下,线程是可连接的,也就是说当其退出时,其它线程可以获取其返回状态。函数pthread_join()等待由thread标识的线程终止,如果线程已经终止则立刻返回,用法如下:

1
2
#include<pthread.h>
int pthread_join(pthread_t thread, void** retval) //返回0代表成功,失败则返回代表失败类型的正数

如果retval是非空指针,将会保存线程终止时返回值的拷贝,即线程调用return或者pthread_exit()时传入的值。

如果线程没有被分离,则必须使用pthread_join来进行连接,否则在线程终止时将会产生一个僵尸线程。

需要注意的是,一个进程的任意线程都可以调用pthread_join与该进程的任何其它线程连接起来,即线程之间的关系对等;这与进程间的层次关系不同,进程只能由父进程对子进程调用wait。但是可以连接不代表任意线程的连接能够成功,可以限制只能连接特定的线程ID,且线程连接也不能以非阻塞方式进行。

有时,我们并不关心线程的返回状态,只希望系统在线程终止时可以自动清理并移除,此时可以使用pthread_detach()系统调用:

1
2
#include<pthread.h>
int pthread_detach(pthread_t thread); //返回0代表成功,失败则返回代表失败类型的正数

这一系统调用传入thread指定要分离的线程标识符,调用成功之后线程便会处于分离状态,在此之后不能再使用pthread_join()来获取其状态,也无法使其重返可连接的状态。

线程同步

互斥量

互斥量用于确保同时仅有一个线程可以访问某一项共享资源,它可以保证访问操作的原子性。互斥量只有两种状态:已锁定和未锁定。任何时候,至多只有一个线程可以锁定该互斥量;而一旦线程锁定互斥量,则成为该互斥量的所有者,只有该线程才可以给互斥量解锁。

一般情况下,对每一个共享资源会使用不同的互斥量,而每一个线程在访问同一资源时将采用如下步骤:针对共享资源锁定互斥量、访问共享资源、对互斥量解锁。

创建一个互斥量的方法分为静态和动态两种。静态分配一个互斥量的方法如下:

1
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;

在静态初始化一个互斥量之后,互斥量处于未锁定状态。

而动态分配与销毁一个互斥量的方法为:

1
2
3
4
#include<pthread.h>
int pthread_mutex_init(pthread_mutex_t* mutex, const pthread_mutexattr_t* attr)
int pthread_mutex_destroy(pthread_mutex_t* mutex)
//返回0代表成功,失败则返回代表失败类型的正数

pthread_mutex_init函数中,参数mutex指定函数执行初始化操作的目标互斥量,参数attr是指向pthread_mutexattr_t类型对象的指针,该对象在函数调用之前已经被初始化处理,用于定义互斥量的属性。如果这一参数被设为NULL,那么互斥量的各种属性将会取默认值。而当动态分配的互斥量mutex不需要再被使用之后,便可以使用pthread_mutex_destroy函数将其销毁。

使用下面两个函数可以锁定或者解锁某个互斥量:

1
2
3
4
5
#include<pthread.h>

int pthread_mutex_lock(pthread_mutex_t* mutex);
int pthread_mutex_unlock(pthread_mutex_t* mutex);
//返回0代表成功,失败则返回代表失败类型的正数

在调用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
2
3
#include<pthread.h>
int pthread_cond_init(pthread_cond_t* cond, const pthread_condattr_t* attr); //参数cond代表要初始化的条件变量,attr参数用来设置条件变量的属性,如果设置为NULL则代表使用默认属性
int pthread_cond_destroy(pthread_cond_t* cond) //当cond不再需要时可以使用这一系统调用将其销毁

条件变量的主要操作是发送信号和等待。发送信号操作即通知一个或多个处于等待状态的线程,某个共享变量的状态已经改变。等待操作是指在收到一个通知前一直处于阻塞状态。相关的函数包括:

1
2
3
4
5
6
#include<pthread.h>
int pthread_cond_signal(pthread_cond_t* cond); //只保证唤醒至少一条遭到阻塞的线程
int pthread_cond_broadcast(pthread_cond_t* cond); //唤醒所有遭到阻塞的线程
int pthread_cond_wait(pthread_cond_t* cond, pthread_mutex_t* mutex); //阻塞一个线程,直至收到条件变量cond的通知
int pthread_cond_timedwait(pthread_cond_t* cond, pthread_mutex_t* mutex, const struct timespec* abstime); //与pthread_cond_wait类似,但是有一个等待时间上限,超过上限则返回ETIMEOUT错误
//返回0代表成功,失败则返回代表失败类型的正数

每个条件变量都有与之相关的判断条件,涉及一个或者多个共享变量。由于代码从pthread_cond_wait返回时,并不能确定判断条件的状态,因此应该重新检查判断条件,在条件不满足的情况下继续等待。所以必须使用while循环而不是if语句来控制对pthread_cond_wait()的调用。

线程安全

若函数可同时供多个线程安全调用,则称之为线程安全函数;反之,如果函数不是线程安全的,则不能并发调用。实现线程安全有多种方式,一是将函数与互斥量关联使用,二是将共享变量与互斥量关联起来。因此,在多线程的程序中如果要用到一些系统调用或者库函数,需要确定它们的线程安全性。

实现函数线程安全最为有效的方式就是使其可重入,应以这种方式来实现所有新的函数库。而对于已有的函数而言,使用线程特有数据技术,可以无需修改函数接口而实现已有函数的线程安全。

要使用线程特有数据的一般步骤如下:

  • 函数创建一个键(key),用来将不同函数使用的线程特有数据项区分开来。可以使用调用函数pthread_key_create()函数来创建,这一调用也允许调用者自定义一个析构函数,用于释放为该键分配的存储块
  • 函数为每个调用者线程创建线程特有的数据块
  • 函数使用pthread_setspecific()pthread_getspecific()来存储或者提取数据

要创建一个新键可以使用pthread_key_create()函数:

1
2
3
4
5
6
#include<pthread.h>
int pthread_key_create(pthread_key_t* key, void (*destructor)(void *)); //成功返回0,失败则返回代表失败类型的正数
void dest(void *value) //destructor的函数格式定义
{
//release storage pointed by value
}

只要线程终止时与key的关联值不为NULL,Pthreads API 会自动执行解构函数,并将与key的关联值作为参数传入解构函数。传入的值通常是与该键关联,且指向线程特有数据块的指针。如果无需解构,那么可将destructor设置为NULL

而存储与取出数据的函数如下:

1
2
3
#include<pthread.h>
int pthread_setspecific(pthread_key_t key, const void* value); //将value对应的数据存储一个副本,并将其与key相关联。成功返回0,失败则返回代表失败类型的正数
void* pthread_getspecific(pthread_key_t key); //返回一个指向数据副本的指针,如果key没有对应的数据则返回NULL

一种更简单的方法是使用线程局部存储。如果要创建线程局部变量,只需要简单地在全局或者静态变量的声明中包含__thread说明符即可:

1
static __thread buf[10]

带有这一说明符的变量,每个线程都拥有一份对变量的拷贝。线程局部存储中的变量将会一直存在直到线程终止。

线程取消

有时候,需要向线程发送请求让它立即退出,例如一个图形用户界面的应用程序的取消按钮就对应于终止后台某一线程正在执行的任务。在这种情况下,主线程(即控制图形用户界面)需要请求后台线程退出。

函数pthread_cancel()向一个指定线程发送取消请求:

1
2
#include<pthread.h>
int pthread_cancel(pthread_t thread); //成功返回0,失败则返回代表失败类型的正数

发送取消请求之后,函数立即返回,不会等待目标线程的退出。目标线程对这一指令的响应过程可以使用下面两个函数进行控制:

1
2
3
4
#include<pthread.h>
int pthread_setcancelstate(int state, int* oldstate)
int pthread_setcanceltype(int type, int* oldtype)
//成功返回0,失败则返回代表失败类型的正数

其中,state参数可以设置为PTHREAD_CANCEL_DISABLE或者PTHREAD_CANCEL_ENABLE,分别对应于线程不可取消和线程可以取消。oldstate用于保存前一个状态。

参数type可以设置为PTHREAD_CANCEL_ASYNCHRONOUS,代表可能会在任何时刻取消线程,因此一般原则是可异步取消的线程不应该分配任何资源,也不能获取互斥量或锁;或者PTHREAD_CANCEL_DEFERED,代表取消请求保持挂起状态直到到达取消点(有一系列的取消点函数,可以查阅相关资料),这也是新建线程的默认类型。参数oldtype保存之前的状态。

如果线程执行的是一个不包含取消点的循环,则永远不会响应取消请求。如果要手动加入取消点,则可以使用下面的函数:

1
2
#include<pthread.h>
void pthread_testcancel(void);

如果一个线程已有处于挂起状态的取消请求,那么只要调用该函数,则线程会立即终止。

在线程执行到取消点时,如果仅仅是直接退出,则很可能会导致一些共享变量或者Pthreads对象处于不一致的状态,导致进程中的其它线程产生错误结果、死锁等。因此,线程可以设置清理函数,当线程被取消时会自动执行这些函数,用法如下:

1
2
3
4
5
6
7
8
#include<pthread.h>
void pthread_cleanup_push(void (*routine)(void*), void* arg); //向清理函数栈添加清理函数,arg值会传入routine中
void pthread_cleanup_pop(int execute); //移除清理函数

void routine(void* arg)
{
//执行清理过程
}

线程实现细节

线程ID

进程内部的每一个线程都有一个唯一的线程ID作为标识。线程ID会返回给pthread_create()的调用者,一个线程可以使用pthread_self()函数来获取自己的线程ID:

1
2
#include<pthread.h>
pthread_t pthread_self(void);

pthread_equal()可以检查两个线程的ID是否相同:

1
2
#include<pthread.h>
int pthread_equal(pthread_t t1, pthread_t t2); //如果相等返回非0,不相等返回0

线程栈

创建线程时,每个线程都有一个属于自己的线程栈,且大小固定。主线程的线程栈要大一些,除此之外的所有线程栈大小都相等。使用函数pthread_attr_setstacksize()可以设置线程栈的大小。

线程和信号

在UNIX信号模型中,一些方面属于进程层面(即进程中的所有线程共享),另一些方面属于线程层面。一些关键规则包括:

  • 信号动作属于进程层面。如果某进程的任一线程收到任何未经(特殊)处理的信号,且其缺省动作为stop 或terminate,那么将停止或者终止该进程的所有线程。
  • 对信号的处置属于进程层面,进程中的所有线程共享对每个信号的处置设置
  • 信号的发送既可针对整个进程,也可针对某个特定线程。如果信号产生源于线程上下文的特定硬件指令执行、线程试图对断开的管道进行写操作、或者由函数pthread_kill()pthread_sigqueue()所发出的信号,那么这些信号是面向线程的,除此之外的信号是面向进程的。
  • 当多线程程序收到一个信号,且该进程已然为此信号创建了信号处理程序时,内核会任选一条线程来接收这一信号,并在该线程中调用信号处理程序对其进行处理。
  • 信号掩码(mask)是针对每个线程而言,每个线程可以设置自己的信号掩码
  • 针对为整个进程所挂起(pending)的信号,以及为每条线程所挂起的信号,内核都分别维护有记录

刚创建的新线程会从其创建者处继承信号掩码的一份拷贝。线程可以使用pthread_sigmask()来改变或/并获取当前的信号掩码:

1
2
#include<signal.h>
int pthread_mask(int how, const sigset_t* set, sigset_t* oldset); //成功返回0,失败返回代表失败类型的正数

它的用法与sigprocmask完全相同。

如果要向一个线程发送信号,可以使用pthread_kill()函数:

1
2
#include<signal.h>
int pthread_kill(pthread_t thread, int sig); //成功返回0,失败返回代表失败类型的正数

Linux特有的函数pthread_sigqueue()pthread_kill()sigqueue()的功能合并,可以向同一进程的另一线程发送携带数据的信号:

1
2
#include<signal.h>
int pthread_sigqueue(pthread_t thread, int sig, const union sigval value); //成功返回0,失败返回代表失败类型的正数

由于没有任何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
2
#include<sched.h>
int clone(int (*func)(void *), void* child_stack, int flags, void* func_arg, pid_t* ptid, struct user_desc* tls, pid_t* ctid); //成功返回子进程编号,失败返回-1

clone生成的子进程在继续运行时会调用func参数指定的函数,它的参数由func_arg指定。flags参数存放位掩码,用于控制clone的操作。

image-20210714115006363
image-20210714115027789

在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系统与信号相关的信息:

image-20210701143620837

其中,信号值在不同的硬件架构下也具有不同的编号。默认列显示的是信号的默认行为,term表示信号终止进程,core表示进程产生核心转储文件并退出,ignore表示忽略该信号,stop表示信号停止了进程,cont表示信号恢复了一个已停止的进程。

信号集

许多信号相关的系统调用都需要能够表示一组不同的信号。多个信号可以使用一个称为信号集的数据结构来表示,其系统数据类型为sigset_t。可以用来操纵信号集的函数有:

1
2
3
4
5
6
7
8
9
10
11
#include<signal.h>
int sigemptyset(sigset_t* set) //初始化一个未包含任何成员的信号集
int sigfillset(sigset_t* set) //初始化一个信号集,使其包含所有信号
int sigaddset(sigset_t* set, int sig) //向一个信号集中添加单个信号
int sigdelset(sigset_t* set, int sig) //移除一个信号集中的单个信号
//上面四个函数返回0表示成功,返回-1表示失败
int sigismember(const sigset_t* set, int sig) //判断信号sig是否是信号集set的成员,如果是则返回1,否则返回0
int sigandset(sigset_t* dest, sigset_t* left, sigset_t* right) //将left与right的交集放在dest集
int sigorset(sigset_t* dest, sigset_t* left, sigset_t* right) //将left与right的并集放在dest集
//上面两个函数返回0表示成功,返回-1表示失败
int sigisemptyset(const sigset_t* set) //如果set是一个空信号集则返回1,否则返回0

信号掩码

内核会为每个进程维护一个信号掩码,即一组信号,并将阻塞其针对该进程的传递。如果将遭阻塞的信号发送给某进程,那么对该信号的传递将延后,直至从进程信号掩码中移除该信号,从而解除阻塞为止。

向信号掩码中添加信号的方式有:

  • 调用信号处理器程序时,可将引发调用的信号自动添加到信号掩码中,是否发生这一情况要视sigaction()函数在安装信号处理器程序时使用的标志而定。
  • 使用sigaction()函数建立信号处理器程序时,可以指定一组额外信号,当调用该处理器程序时会将其阻塞。
  • 使用sigprocmask()系统调用,随时显式地向信号掩码中添加或者移除信号。

sigprocmask()的使用方法如下:

1
2
#include<signal.h>
int sigprocmask(int how, const sigset_t* set, sigset_t* oldset) //返回0代表成功,-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
2
#include<signal.h>
void(*signal(int sig, void (*handler)(int))) (int);

其中,第一个参数sig标识希望修改处置的信号编号;第二个参数handler则标识信号抵达时所调用函数的地址。该函数无返回值,并接受一个整型参数。handler所对应的信号处理器函数一般具有如下格式:

1
2
3
4
void handler(int sig)
{
/*Code for handler*/
}

如果调用signal成功,则返回之前的信号处置函数,它是一枚指针,指向带有一个整型参数且无返回值的函数。

handler参数也可以用如下值来代替函数地址:

  • SIG_DFL:将信号处置重置为默认值
  • SIG_IGN:忽略该信号

sigaction的用法比signal要更灵活一些。sigaction()允许在获取信号处置的同时无需将其改变,并且,还可设置各种属性对调用信号处理器程序时的行为施以更加精准的控制。此外,在建立信号处理器程序时,sigaction()较之signal()函数可移植性更佳。它的用法如下:

1
2
#include<signal.h>
int sigaction(int sig, const struct sigaction* act, struct sigaction* oldact) //返回0表示成功,-1表示失败

sig参数标识想要获取或改变的信号编号。该参数可以是除去SIGKILL和SIGSTOP之外的任何信号。act参数是一枚指针,指向描述信号新处置的数据结构。如果仅对信号的现有处置感兴趣,那么可将该参数指定为NULLoldact参数是指向同一结构类型的指针,用来返回之前信号处置的相关信息。如果无意获取此类信息,那么可将该参数指定为NULL

sigaction结构类型如下所示:

1
2
3
4
5
6
7
8
9
struct sigaction{
union{
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t*, void*);
}__sigaction_handler; //对应于signal函数的handler参数
sigset_t sa_mask; //信号掩码
int sa_flags; //位掩码
void (*sa_restorer)(void); //仅供内部使用,用于确保信号处理器程序完成之后,会调用专用的sigreturn()系统调用,借此来恢复进程的执行上下文
};

sa_mask字段定义了一组信号,在调用由sa_handler所定义的处理器程序时将阻塞该组信号。当调用信号处理器程序时,会在调用信号处理器之前,将该组信号中当前未处于进程掩码之列的任何信号自动添加到进程掩码中。这些信号将保留在进程掩码中,直至信号处理器函数返回,届时将自动删除这些信号。此外,引发对处理器程序调用的信号将自动添加到进程信号掩码中,保证不会递归地中断自己。

sa_flags字段是一个位掩码,指定用于控制信号处理过程中的各种选项。

信号处理器函数

设计原则

信号处理器程序(也称为信号捕捉器)是当指定信号传递给进程时将会调用的一个函数。调用信号处理器程序,可能会随时打断主程序流程;内核代表进程来调用处理器程序,当处理器返回时,主程序会在处理器打断的位置恢复执行。

一般而言,信号处理器函数设计地越简单越好,这将降低引发竞争条件的风险。两种常见的设计方式为:

  1. 信号处理器函数设置全局性标志变量并退出。主程序对此标志进行周期性检查,一旦置位便采取相应动作
  2. 信号处理器函数执行某种类型的清理动作,接着终止进程或者使用非本地跳转,将栈解开并将控制返回到主程序的预定位置

在信号处理器函数中,并非所有的系统调用和库函数都可以安全调用。在编写信号处理器函数时有两种选择:

  • 确保信号处理器函数代码本身可重入,且只调用异步信号安全的函数
  • 当主程序执行不安全函数,或者操作信号处理器函数也可以更新的全局数据结构时,阻塞信号的传递(这一要求有些困难,)

备注—可重入与异步信号安全的概念

可重入指的是,函数由两条或多条线程调用时,即便是交叉执行,其效果也与各线程以未定义顺序依次调用时一致。例如更新全局变量或静态数据结构的函数可能是不可重入的。

如果某一函数是可重入的,又或者信号处理器函数无法将其中断时,就称该函数是异步信号安全的。

而如果必须要共享某些全局变量,则可以在声明变量的时候使用volatile关键字,并且使用sig_atomic_t来保证读写操作的原子性。也就是说,所有在主程序和信号处理器函数之间共享的全局变量应声明为:

1
volatile sig_atomic_t flag

终止信号处理器函数

信号处理器函数的终止方式包括:

  • 返回主程序
  • 使用_exit()终止进程,处理器函数可以提前做一些清理工作
  • 使用kill()发送信号来杀掉进程
  • 从信号处理器函数中执行非本地跳转
  • 使用abort()函数终止进程,并产生核心转储

如果使用longjmp()来退出信号处理器函数,这一系统调用是否会恢复信号掩码取决于具体的UNIX实现。因此,最好是使用如下一对系统调用:

1
2
3
#include<setjmp.h>
int sigsetjmp(sigjmp_buf env, int savesigs) //第一次调用设置跳转点时返回0,通过siglongjmp返回跳转点时返回非0值
void siglongjmp(sigjmp_buf env, int val)

sigsetjmp函数中多出了一个参数savesigs,如果它被设置为非0值,那么sigsetjmp会将进程的当前掩码保存在env中,之后通过相同env参数的siglongjmp调用进行恢复;如果它被设置为0,则不会保存和恢复进程的信号掩码。

需要注意的是,这两个函数都不是异步信号安全的。

abort()的使用方法如下:

1
2
#include<stdlib.h>
void abort(void);

函数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
2
#include<signal.h>
int kill(pid_t pid, int sig) //返回0表示成功,-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
2
#include<signal.h>
int raise(int sig) //成功返回0,失败返回一个非0值

killpg()函数向某一进程组的所有成员发送一个信号,用法如下:

1
2
#include<signal.h>
int killpg(pid_t pgrp, int sig) //成功返回0,失败返回-1

显示信号描述

每个信号都有一串与之相关的可打印说明,这些描述位于数组sys_siglist中。例如可以直接使用sys_siglist[SIGPIPE]来获取对SIGPIPE信号的描述。另一种办法是使用strsignal函数:

1
2
3
4
#include<signal.h>
#include<string.h>
char* strsignal(int sig)
void psignal(int sig, const char* msg)

strsignal函数对sig参数进行边界检查,然后返回一枚指针,指向针对于该信号的可打印描述字符串,或者是当信号编号无效时指向错误字符串。

psignal函数所示为msg参数所给定的字符串,后面跟有一个冒号,随后是对应于sig的信号描述。

处于等待状态的信号

如果某进程接受了一个该进程正在阻塞的信号,那么会将该信号填加到进程的等待信号集。当(且如果)之后解除了对该信号的锁定时,会随之将信号传递给此进程。sigpending()系统调用可以确定进程中处于等待状态的信号:

1
2
#include<signal.h>
int sigpending(sigset_t* set) //返回0表示成功,-1表示失败

这一调用为调用进程返回处于等待状态的信号集,并将其置于set指向的sigset_t结构中。而等待信号集仅仅是一个掩码,仅表明信号是否发生,而未表明其发生的次数。如果同一个信号在阻塞状态下发生多次,那么会将该信号记录在等待信号集中,并在随后只传递一次。

等待信号

调用pause将暂停进程执行,直到信号处理器函数中断该调用,或者一个未处理信号终止进程。也就是说,只有当前进程接收到信号之后,进程才可能会继续执行下去,否则会一直等待信号的到来。

1
2
#include<unistd.h>
int pause(void) //返回-1,并且将errno设置为EINTR

处理信号时,pause()会遭到中断并返回。

在对信号编程时偶尔会遇到如下的情况,需要临时阻塞一个信号,以防止其信号处理器不会将某些关键代码片段的执行中断,然后解除对这一信号的阻塞并暂停执行,直到有信号到达。而解除并暂停执行这一步操作需要保证其原子性,否则可能会出现竞争条件。因此,可以使用sigsuspend系统调用:

1
2
#include<signal.h>
int sigsuspend(const sigset_t* mask)

这一系统调用将mask所指向的信号集来替换进程的信号掩码,然后挂起进程的指向,直到其捕获到信号,并从信号处理器中返回。一旦处理器返回,进程的信号掩码将被恢复为调用前的值。

sigsuspend()因信号的传递而中断,则将返回−1,并将errno置为EINTR。如果mask指向的地址无效,则sigsuspend()调用失败,并将errno置为EFAULT。

另一个替代方案是使用sigwaitinfo()系统调用,可以用来同步接收信号:

1
2
#include<signal.h>
int sigwaitinfo(const sigset_t* set, siginfo_t* info) //成功则返回发送的信号数目,失败返回-1

这一系统调用会挂起进程的执行,直到set所对应信号集中的某一信号抵达。如果在调用时,set中的某一信号已经处于等待状态,那么函数会立即返回。传递来的信号就会从进程的等待信号队列中移除,并将返回信号编号作为函数结果。

info参数如果不为空,则会指向经初始化处理的siginfo_t结构,其中包含的信息与提供给信号处理器函数的这一参数相同。

它的一个变体是sigtimedwait()系统调用,这一函数允许指定等待时限:

1
2
#include<signal.h>
int sigtimedwait(const sigset_t* set, siginfo_t* info, const struct timespec* timeout) //成功则返回发送的信号数目,失败返回-1

其中timeout参数指向一个timespec数据结构,是指向如下数据结构的一枚指针:

1
2
3
4
struct timespec{
time_t tv_sec;
long tv_nsec;
}

通过文件描述符获取信号

Linux提供了一个非标准的signalfd()系统调用,利用它可以创建一个特殊的文件描述符,发往调用者的信号都可以从该描述符中读取。用法如下:

1
2
#include<sys/signalfd.h>
int signalfd(int fd, const sigset_t* mask, int flags) //成功则返回相应的文件描述符,失败返回-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
2
#include<signal.h>
int sigqueue(pid_t pid, int sig, const union sigval value) //返回0代表成功,-1代表失败

使用sigqueue发送信号的权限与kill的要求一致,也可以发送空信号(即信号0)。但是sigqueue不能通过将pid设置为负值而向整个进程组发送信号。参数value是一个sigval类型的联合体,指定了信号的伴随数据,具有以下形式:

1
2
3
4
union sigval{
int sival_int;
void* sival_ptr;
}

一旦触及到排队信号的数量限制,sigqueue调用将会失败,同时将errno设置为EAGAIN,表示需要再次发送信号。

处理实时信号

实时信号的处理方式与标准信号一样,可以使用signal()或者sigaction()函数来处理实时信号。

进程间通信

UNIX 系统上各种通信和同步工具可以根据功能分成三类:

  • 通信:这些工具关注进程之间的数据交换。
  • 同步:这些进程关注进程和线程操作之间的同步。
  • 信号:尽管信号的主要作用并不在此,但在特定场景下仍然可以将它作为一种同步技术。更罕见的是信号还可以作为一种通信技术:信号编号本身是一种形式的信息,并且可以在实时信号上绑定数据(一个整数或指针)。

这些工具的分类如下图所示:

image-20210715103944465

通常使用通用术语进程间通信(IPC)指代所有这些工具。

管道和FIFO

概述

管道可以用来在相关进程之间传递数据。FIFO 是管道概念的一个变体,它们之间的一个重要差别在于FIFO 可以用于任意进程间的通信。一个管道有如下几个特征:

  • 一个管道是一个字节流。从管道中读取数据的进程可以读取任意大小的数据块,而不管写入进程写入管道的数据块的大小是什么。此外,通过管道传递的数据是顺序的,从管道中读取出来的字节的顺序与它们被写入管道的顺序是完全一样的。
  • 如果试图从一个当前为空的管道中读取数据,将会被阻塞直到至少有一个字节被写入到管道中为止。如果管道的写入端被关闭了,那么从管道中读取数据的进程在读完管道中剩余的所有数据之后将会看到文件结束,即read()返回0
  • 管道的数据传递方向是单向的,一端用于写入,另一端用于读取
  • 如果多个进程写入同一个管道,那么如果它们在一个时刻写入的数据量不超过PIPE_BUF字节(Linux系统下这个值为4096),那么就可以确保写入的数据不会发生相互混合的情况。
  • 管道其实是一个在内核内存中维护的缓冲器,这个缓冲器的存储能力是有限的。一旦管道被填满之后,后续向该管道的写入操作就会被阻塞直到读者从管道中移除了一些数据为止。

创建与使用

pipe系统调用可以创建一个新的管道:

1
2
#include<unistd.h>
int pipe(int filedes[2]); //返回0表示成功,-1表示失败

如果调用成功,则会在数组filedes中返回两个打开的文件描述符,filedes[0]表示管道的读取端,filedes[1]表示管道的写入端。

与所有的文件描述符一样,可以使用readwrite系统调用在管道上执行I/O操作。一旦向管道的写入端写入数据之后立即就能从管道的读取端读取数据。管道上的read()调用会读取的数据量为所请求的字节数与管道中当前存在的字节数两者之间较小的那个,而管道为空时则阻塞。也可以在管道上使用stdio函数(printf()scanf()等),只需要首先使用fdopen()获取一个与filedes中的某个描述符对应的文件流即可。

由于子进程会继承父进程的文件描述符的副本,因此可以通过管道来实现相关进程之间的通信。相关进程指的是这些进程来自于同一个祖先进程,且管道由祖先进程所创建。这些进程必须关闭未使用的管道文件描述符,否则会导致出错。

当所有子进程都关闭了管道的写入端的文件描述符之后,父进程在管道上的read()就会结束并返回文件结束(0)。根据这一特性可以将管道作为一种进程同步的方法。这种方法可以同来协调一个进程的动作使之与多个其他(相关)进程匹配。当然,也可以使用其它的同步结构。

创建管道时,为管道两端分配文件描述符将会优先选择可用描述符中数值最小的,因此可以使用这一特性,将管道的输入和输出端绑定为进程的标准输入或输出。可以通过创建管道时先关闭标准输入/输出,或者是使用dup2来复制文件描述符。

与shell命令通信

管道的一个常见用途是执行shell命令,并读取其输出或向其发送一些输入。popenpclose函数可以简化这一任务:

1
2
3
#include<stdio.h>
FILE* popen(const char* command, const char* mode); //成功返回文件流,失败返回NULL,并设置errno表示失败原因
int pclose(FILE* stream); //成功则返回子进程的结束状态,失败返回-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
2
#include<sys/stat.h>
int mkfifo(const char* pathname, mode_t mode); //返回0表示成功,-1表示失败

当一个进程打开一个FIFO的一端时,如果FIFO的另一端还没有被打开,那么该进程会被阻塞。但有些时候阻塞并不是期望的行为,而这可以通过在调用open()时指定O_NONBLOCK标记来实现。如果打开FIFO是为了写入,并且还没有打开FIFO的另一端来读取数据,那么open()调用会失败,并将errno设置为ENXIO。

读写语义

管道和FIFO上read()操作的语义可以总结为下表:

image-20210715141636626

write()操作的语义如下表:

image-20210715141715046

System V IPC

简介

System V IPC是首先在System V中被广泛使用的三种IPC机制的名称并且之后被移植到了大多数UNIX实现中以及被加入了各种标准中。System V IPC包括三种不同的进程间通信机制:消息队列、信号量和共享内存。System V IPC的编程接口可汇总为下表:

image-20210715143528480

消息队列

创建或打开

使用msgget()系统调用可以创建一个新的消息队列,或者获得一个已有队列的标识符:

1
2
3
4
#include<sys/types.h>
#include<sys/msg.h>

int msgget(key_t key, int msgflg); //成功则返回消息队列的编号,失败返回-1

key参数可以设置为IPC_PRIVATE,这样会创建一个新的IPC对象;或者是使用ftok()函数生成。msgflg参数可以为0,或者加上掩码IPC_CREAT(如果key不存在则创建新的消息队列)、IPC_EXCL(如果指定IPC_CREAT且key对应的队列已存在,则返回错误)。

消息交换

消息队列上的I/O操作可以使用下面的函数:

1
2
3
4
5
6
7
8
9
10
#include<sys/types.h>
#include<sys/msg.h>

int msgsnd(int msqid, const void* msgp, size_t msgsz, int msgflg); //成功返回0,失败返回-1
ssizt_t msgrcv(int msqid, void* msgp, size_t maxmsgsz, long msgtyp, int msgflg); //成功返回读取的字节数,失败返回-1
//msgp参数通常写为如下的数据结构:
struct mymsg{
long mtype;
char mtext[];
}

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
2
3
4
#include<sys/types.h>
#include<sys/msg.h>

int msgctl(int msqid, int cmd, struct msqid_ds* buf); //成功返回0,失败返回-1

msqid代表要操作的消息队列;cmd指定队列上要执行的操作,它的取值为如下三个中的一个:

  • IPC_RMID:立即删除消息队列对象及其关联的msqid_ds数据结构,队列中所有剩余消息丢失,所有被阻塞的读写进程会被唤醒
  • IPC_STAT:将与这个消息队列关联的msqid_ds数据结构的副本放到buf指向的缓冲区中
  • IPC_SET:使用buf指向的缓冲区提供的值更新与这个消息队列关联的msqid_ds数据结构中被选中的字段。

关联数据结构

每个消息队列都有一个关联的msqid_ds数据结构,形式如下:

1
2
3
4
5
6
7
8
9
10
11
struct msqid_ds{
struct ipc_perm msg_perm; //所有权和许可
time_t msg_stime; //创建队列时为0,后续每次调用msgsnd成功都会将其设置为当前时间
time_t msg_rtime; //创建队列时为0,后续每次调用msgrcv成功都会将其设置为当前时间
time_t msg_ctime; //创建消息队列,以及执行IPC_SET成功都会将其设置为当前时间
unsigned long __msg_cbytes; //创建时为0,后续代表所有消息的mtext字段包含字节数总和
msgqnum_t msg_qnum; //创建时为0,后面代表队列的消息总数
msglen_t msg_qbytes; //mtext字段的字节总数上限
pid_t msg_lspid; //创建时为0,后面每次调用msgsnd成功会设置为调用进程的ID
pid_t msg_lrpid; //创建时为0,后面每次调用msgrcv成功会设置为调用进程的ID
}

缺点

  1. 消息队列是通过标识符引用的,而不是像大多数其他UNIX I/O机制那样使用文件描述符。因此无法使用基于文件描述符的I/O技术
  2. 使用键而不是文件名来标识消息队列,会增加额外的程序设计复杂性
  3. 消息队列无连接,内核不会维护引用队列的进程数
  4. 消息队列总数、消息大小以及单个队列的容量都有限制

信号量

简介

System V信号量不是用来在进程间传输数据的,而是用来同步进程的动作。信号量的一个常见用途是同步对一块共享内存的访问以防止出现一个进程在访问共享内存的同时另一个进程更新这块内存的情况。

一个信号量是一个由内核维护的整数,其值被限制为大于或等于0。在一个信号量上可以执行各种操作(即系统调用),包括:

  • 将信号量设置为一个绝对值
  • 在信号量当前值的基础上加/减1
  • 等待信号量的值等于0

当减小一个信号量的值时,内核会将所有试图将信号量值降低到0之下的操作阻塞。如果信号量的当前值不为0,那么等待信号量的值等于0的调用进程将会发生阻塞。

使用System V 信号量的常规步骤如下。

  • 使用semget()创建或打开一个信号量集。
  • 使用semctl() SETVAL或SETALL操作初始化集合中的信号量。(只有一个进程需要完成这个任务。
  • 使用semop()操作信号量值。使用信号量的进程通常会使用这些操作来表示一种共享资源的获取和释放。
  • 当所有进程都不再需要使用信号量集之后使用semctl() IPC_RMID 操作删除这个集合。(只有一个进程需要完成这个任务。)

创建或打开

semget()系统调用创建一个新信号量集,或者获取一个既有集合的标识符:

1
2
3
4
#include<sys/types.h>
#include<sys/sem.h>

int semget(key_t key, int nsems, int semflg); //成功返回信号量的标识符,失败返回-1

key参数可以设置为IPC_PRIVATE,这样会创建一个新的IPC对象;或者是使用ftok()函数生成。如果要创建一个新的信号量集,那么nsem参数会指定信号量的数量(必须大于0);如果要获取一个既有集的标识符,则nsem参数需要小于或等于集合大小。semflg参数为一个位掩码,指定了施加于新信号量集之上的权限或需检查的一个既有集合的权限。

控制信号量

semctl()系统调用在一个信号量集或集合中的单个信号量上执行各种控制操作:

1
2
3
4
#include<sys/types.h>
#include<sys/sem.h>

int semctl(int semid, int semnum, int cmd, union semun arg);

semid参数是操作所施加的信号量集的标识符。对于那些在单个信号量上执行的操作,semnum参数标识出了集合中的具体信号量。对于其他操作则会忽略这个参数,并且可以将其设置为0。cmd参数指定了需执行的操作。

第四个参数arg是一个union类型的变量,需要在程序中显式定义这个类型:

1
2
3
4
5
6
7
8
union semun{
int val;
struct semid_ds* buf;
unsigned short* array;
#if defined(__linux__)
struct seminfo* __buf;
#endif
}

cmd参数可以设置的值及其对应的操作如下:

  • 常规控制操作:
    • IPC_RMID:立即删除信号量集及其关联的semid_ds数据结构
    • IPC_STAT:在arg.buf指向的缓冲器中放置一份与这个信号量集相关联的semid_ds数据结构的副本
    • IPC_SET:使用arg.buf指向的缓冲器中的值来更新与信号量集关联的semid_ds数据结构中的字段
  • 获取和初始化信号量值
    • GETVAL:返回由semid指定的信号量中第semnum个信号量的值
    • SETVAL:将semid指定的信号量集中的第semnum个信号量的值初始化为arg.val
    • GETALL:获取由semid指向的信号量集中所有信号量的值并将它们放在arg.array指向的数组中。程序员必须要确保该数组具备足够的空间。这个操作将忽略semnum参数
    • SETALL:使用arg.array指向的数组中的值初始化semid指向的集合中的所有信号量。这个操作将忽略semnum参数
  • 获取单个信号量的信息
    • GETPID:返回上一个在该信号量上执行semop()进程的ID
    • GETNCNT:返回正在等待信号量值增长的进程数
    • GETZCNT:返回正在等待信号量的值变为0的进程数

信号量关联数据结构

每个信号量集都有一个关联的semid_ds数据结构,其形式如下:

1
2
3
4
5
6
struct semid_ds{
struct ipc_perm sem_perm; //在创建信号量集时初始化这个子结构中的字段
time_t sem_otime; //在创建信号量集时会将这个字段设置为0,然后在每次成功的semop()调用或当信号量值因SEM_UNDO操作而发生变更时将这个字段设置为当前时间
time_t sem_ctime; //在创建信号量时以及每个成功的IPC_SET、SETALL和SETVAL操作执行完毕之后将这个字段设置为当前时间
unsigned long sem_nsems; //在创建集合时将这个字段的值初始化为集合中信号量的数量
}

信号量操作

semop系统调用在semid标识的信号量集中的信号量上面执行一个或者多个操作:

1
2
3
4
5
#include<sys/types.h>
#include<sys/sem.h>

int semop(int semid, struct sembuf* sops, unsigned int nsops); //返回0代表成功,-1代表失败
int semtimedop(int semid, struct sembuf* sops, unsigned int nsops, struct timespec* timeout); //可以指定调用阻塞的时间上限,返回0代表成功,-1代表失败

sops参数是一个指向数组的指针,数组中包含了需要执行的操作,nsops参数给出了数组的大小(数组至少需包含一个元素)。操作将会按照在数组中的顺序以原子的方式被执行。sop数组中的元素是形式如下的结构:

1
2
3
4
5
struct sembuf{
unsigned short sem_num; //代表在哪个信号量上面执行操作
short sem_op; //大于0就把它的值加到信号量值;等于0则检查信号量确定它是否为0,不为0则阻塞;小于0就把信号量的值减去它,如果相减为负值则会一直阻塞直到操作之后不会出现负值
short sem_flg; //可以为IPC_NOWAIT和SEM_UNDO
}

如果存在多个因减小一个信号量值而发生阻塞的进程,它们对该信号量减去的值是一样的,那么当条件允许时无法确定到底哪个进程会首先被允许执行操作。另一方面,如果多个因减小一个信号量值而发生阻塞的进程对该信号量减去的值是不同的,那么会按照先满足条件先服务的顺序来进行。

假设一个进程在调整完一个信号量值(如减小信号量值使之等于0)之后终止了,不管是有意终止还是意外终止。在默认情况下,信号量值将不会发生变化。这样就可能会给其他使用这个信号量的进程带来问题。为避免这种问题的发生,在通过semop()修改一个信号量值时可以使用SEM_UNDO标记。当指定这个标记时,内核会记录信号量操作的效果,然后在进程终止时撤销这个操作。不管进程是正常终止还是非正常终止,撤销操作都会发生。

缺点

  • 信号量是通过标识符而不是大多数UNIX I/O 和IPC 所采用的文件描述符来引用的。这使得执行诸如同时等待一个信号量和文件描述符的输入之类的操作就会变得比较困难。
  • 使用键而不是文件名来标识信号量增加了额外的编程复杂度。
  • 创建和初始化信号量需要使用单独的系统调用意味着在一些情况下必须要做一些额外的编程工作来防止在初始化一个信号量时出现竞争条件。
  • 内核不会维护引用一个信号量集的进程数量。这就给确定何时删除一个信号量集增加了难度,并且难以确保一个不再使用的信号量集会被删除。
  • System V 提供的编程接口过于复杂。在通常情况下,一个程序只会操作一个信号量。同时操作集合中多个信号量的能力有时侯是多余的。
  • 信号量的操作存在诸多限制。这些限制是可配置的,但如果一个应用程序超出了默认限制的范围,那么在安装应用程序时就需要完成额外的工作。

共享内存

简介

共享内存允许两个或多个进程共享物理内存的同一块区域(通常被称为段)。由于一个共享内存段会成为一个进程用户空间内存的一部分,因此这种IPC机制无需内核介入。所有需要做的就是让一个进程将数据复制进共享内存中,并且这部分数据会对其他所有共享同一个段的进程可用。

另一方面,共享内存这种IPC机制不由内核控制意味着通常需要使用某些同步方法,使得进程不会出现同时访问共享内存的情况。

为使用一个共享内存段通常需要执行下面的步骤。

  • 调用 shmget()创建一个新共享内存段或取得一个既有共享内存段的标识符(即由其他进程创建的共享内存段)。这个调用将返回后续调用中需要用到的共享内存标识符。
  • 使用shmat()来附上共享内存段,即使该段成为调用进程的虚拟内存的一部分。
  • 此刻在程序中可以像对待其他可用内存那样对待这个共享内存段。为引用这块共享内存,程序需要使用由shmat()调用返回的addr 值,它是一个指向进程的虚拟地址空间中该共享内存段的起点的指针。
  • 调用shmdt()来分离共享内存段。在这个调用之后,进程就无法再引用这块共享内存了。这一步是可选的,并且在进程终止时会自动完成这一步。
  • 调用shmctl()来删除共享内存段。只有当当前所有附加内存段的进程都与之分离之后内存段才会被销毁。只有一个进程需要执行这一步。

创建或打开

shmget()系统调用创建一个新的共享内存段或者获取一个既有段的标识符,新创建的内存段中,所有内容会被初始化为0:

1
2
3
4
#include<sys/types.h>
#include<sys/shm.h>

int shmget(key_t key, size_t size, int shmflg); //成功则返回共享内存段的标识符,失败返回-1

key参数是IPC_PRIVATE值或由ftok()生成的键。size代表要分配的字节数,会被提升到最近的系统分页大小的整数倍。shmflg参数用于控制shmget的操作,可以为IPC_CREAT、IPC_EXCL、SHM_HUGETLB(允许使用巨页的共享内存段)、SHM_NORESERVE这四个值进行或运算的结果。

使用

shmat()系统调用可以将shmid标识的共享内存段附加到调用进程的虚拟地址空间:

1
2
3
4
#include<sys/types.h>
#include<sys/shm.h>

void* shmat(int shmid, const void* shmaddr, int shmflg); //成功则返回共享内存段的地址,失败返回NULL

shmaddrshmflg位掩码参数中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
2
3
4
#include<sys/types.h>
#include<sys/shm.h>

int shmdt(const void* shmaddr); //返回0表示成功,-1表示失败

通过fork()创建的子进程会继承其父进程附加的共享内存段。因此,共享内存为父进程和子进程之间的通信提供了一种简单的IPC 方法。

exec()操作之后,所有附加的共享内存段都会被分离。在进程终止之后共享内存段也会自动被分离。

控制操作

shmctl()系统调用在shmid标识的共享内存段上执行一组控制操作:

1
2
3
4
#include<sys/types.h>
#include<sys/shm.h>

int shmctl(int shmid, int cmd, struct shmid_ds* buf); //返回0表示成功,-1表示失败

cmd参数规定了待执行的控制操作,buf参数与控制操作有关,只有部分操作需要指定它的值,其余操作可以设为NULL。

可以执行的操作如下:

  • 常规控制操作:
    • IPC_RMID:标记这个共享内存段及其关联shmid_ds数据结构以便删除。如果当前没有进程附加该段,那么就会执行删除操作,否则就在所有进程都已经与该段分离(即当shmid_ds数据结构中shm_nattch字段的值为0 时)之后再执行删除操作。
    • IPC_STAT:将与这个共享内存段关联的shmid_ds数据结构的一个副本放置到buf指向的缓冲区中。
    • IPC_SET:使用buf指向的缓冲区中的值来更新与这个共享内存段相关联的shmid_ds数据结构中被选中的字段。
  • 加锁和解锁共享内存:
    • SHM_LOCK:操作将一个共享内存段锁进内存。
    • SHM_UNLOCK:操作为共享内存段解锁以允许它被交换出去。

共享内存关联数据结构

每个共享内存段都有一个关联的shmid_ds数据结构,其形式如下:

1
2
3
4
5
6
7
8
9
10
struct shmid_ds{
struct ipc_perm shm_perm; //所有权和权限
size_t shm_segsz; //共享内存段所需要的字节数,即shmget()调用中size的值
time_t shm_atime; //在创建共享内存段时会将这个字段设置为0,当一个进程附加该段时会将这个字段设置为当前时间
time_t shm_dtime; //在创建共享内存段时会将这个字段设置为0,当一个进程与该段分离之后会将这个字段设置为当前时间
time_t shm_ctime; //当段被创建时以及每个成功的IPC_SET操作都会将这个字段设置为当前时间。
pid_t shm_cpid; //被设置成使用shmget()创建这个段的进程ID。
pid_t shm_lpid; //在创建共享内存段时会将这个字段设置为0,后续每个成功的shmat()或shmdt()调用会将这个字段设置成调用进程的ID。
shmatt_t shm_nattch; //统计当前附加该段的进程数。在创建段时会将这个字段初始化为0,然后每次成功的shmat()调用会递增这个字段的值,每次成功的shmdt()调用会递减这个字段的值
}

POSIX IPC

简介

POSIX.1b实时扩展定义了一组IPC机制,它们与System V IPC 机制类似。这组机制中包括消息队列、信号量和共享内存。它们的编程接口总结如下:

image-20210716155455960

要访问一个POSIX IPC对象就必须要通过某种方式来识别出它。规定的唯一一种用来标识POSIX IPC对象的可移植的方式是使用以斜线打头后面跟着一个或多个非斜线字符的名字,如/myobject

消息队列

打开、关闭和断开

打开一个消息队列可以使用mq_open函数,调用成功则返回一个消息队列描述符:

1
2
3
4
5
#include<fcntl.h>
#include<sys/stat.h>
#include<mqueue.h>

mqd_t mq_open(const char* name, int oflag, mode_t mode, struct mq_attr* attr); //成功则返回消息队列描述符,失败返回-1

其中,name参数为消息队列的标识;oflag参数是位掩码,可以包含的值有O_CREAT、O_EXCL、O_RDONLY、O_WRONLY、O_RDWR和O_NONBLOCK;mode参数是一个位掩码,用于指定施加于消息队列的权限,它可取的值与文件上的掩码值一样;attr参数指定了新消息队列的特性,如果使用NULL则使用默认特性创建队列。

消息队列描述符和打开着的消息队列之间的关系,与文件描述符和打开着的文件描述符之间的关系类似。消息队列描述符是一个进程级别的句柄,它引用了系统中打开着的消息队列描述表中的一个条目,而该条目则引用了一个消息队列对象。

fork()中,子进程会接收其父进程的消息队列描述符的副本,并且这些描述符会引用同样的打开着的消息队列描述符。当一个进程执行了一个exec()或终止时,所有其打开的消息队列描述符会被关闭。

消息队列的关闭使用mq_close函数:

1
2
3
#include<mqueue.h>

int mq_close(mqd_t mqdes); //成功返回0,失败返回-1

关闭一个消息队列并不会删除该队列。要删除队列则需要使用mq_unlink()

1
2
3
#include<mqueue.h>

int mq_unlink(const char* name); //成功返回0,失败返回-1

消息队列特性

消息队列所具有的特性被保存在mq_attr结构中,它的形式如下:

1
2
3
4
5
6
struct mq_attr{
long mq_flags; //消息队列特性标识符
long mq_maxmsg; //消息队列中最大的信息数量
long mq_msgsize; //消息队列中每条信息大小上限
long mq_curmsgs; //消息队列中目前的消息数量
}

要获取一个消息队列的信息,可以使用mq_getattr()函数:

1
2
3
#include<mqueue.h>

int mq_getattr(mqd_t mqdes, struct mq_attr* attr); //返回0代表成功,-1表示失败

要修改消息队列特性,可以使用mq_setattr()函数:

1
2
3
#include<mqueue.h>

int mq_setattr(mqd_t mqdes, const struct mq_attr* newattr, struct mq_attr* oldattr); //返回0代表成功,-1表示失败

交换消息

要发送消息到消息队列,可以使用mq_send函数:

1
2
3
#include<mqueue.h>

int mq_send(mqd_t mqdes, const char* msg_ptr, size_t msg_len, unsigned int msg_prio); //返回0表示成功,-1表示失败

其中,msg_len指定了msg_ptr指向的消息的长度,其值必须小于或等于队列的mq_msgsize特性,否则会返回EMSGSIZE错误。msg_prio表示消息的优先级,消息在队列中是按照优先级的倒序排列,0表示优先级最低。

如果消息队列已经满了(即已经达到了队列的mq_maxmsg限制),那么后续的mq_send()调用会阻塞直到队列中存在可用空间为止,或者在O_NONBLOCK标记起作用时立即失败并返回EAGAIN错误。

mq_receive函数从mqdes引用的消息队列中删除一条优先级最高、存在时间最长的消息,并把删除的消息放置在msg_ptr指向的缓冲区:

1
2
3
#include<mqueue.h>

ssize_t mq_receive(mqd_t mqdes, char* msg_ptr, size_t msg_len, unsigned int* msg_prio); //成功则返回接收的字节数,失败返回-1

不管消息的实际大小是什么,msg_len(即msg_ptr指向的缓冲区的大小)必须要大于或等于队列的mq_msgsize特性,否则mq_receive()就会失败并返回EMSGSIZE错误。如果msg_prio不为NULL,那么接收到的消息的优先级会被复制到msg_prio指向的位置处。

如果消息队列当前为空,那么mq_receive()会阻塞直到存在可用的消息,或在O_NONBLOCK标记起作用时会立即失败并返回EAGAIN 错误。

如果要为发送和接收消息设置超时时间,则可以使用下面的两个函数:

1
2
3
4
5
#include<mqueue.h>
#include<time.h>

int mq_timedsend(mqd_t mqdes, const char* msg_ptr, size_t msg_len, unsigned int msg_prio, const struct timespec* abs_timeout); //成功则返回0,失败返回-1
ssize_t mq_timedreceive(mqd_t mqdes, char* msg_ptr, size_t msg_len, unsigned int* msg_prio, const struct timespec* abs_timeout); //成功则返回接收的字节数,失败返回-1

消息通知

POSIX 消息队列能够接收之前为空的队列上有可用消息的异步通知(即队列从空变成了非空)。这个特性意味着已经无需执行一个阻塞的调用,或将消息队列描述符标记为非阻塞并在队列上定期执行mq_receive()调用。进程可以选择通过信号的形式,或通过在一个单独的线程中调用一个函数的形式来接收通知。

mq_notify函数使得调用进程在消息描述符mqdes引用的空队列有一条消息进入时,可以接收到通知:

1
2
3
#include<mqueue.h>

int mq_notify(mqd_t mqdes, const struct sigevent* notification); //成功则返回0,失败返回-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
2
3
4
5
#include<fcntl.h>
#include<sys/stat.h>
#include<semaphore.h>

sem_t* sem_open(const char* name, int oflag, mode_t mode, unsigned int value); //成功则返回信号量的一个指针,失败返回NULL

其中,name为信号量的名称,oflag参数是一个位掩码,它确定是打开一个已有的信号量(将oflag设置为0)还是创建并打开一个新的信号量(oflag的值为O_CREAT且name对应的信号量不存在)。如果这一函数被用来打开一个既有信号量,则调用时只需要传入nameoflag参数;如果要创建一个新的信号量,则还需要指定modevalue参数。其中mode是一个位掩码,指定了新信号量的权限,它可取的值与文件上的位值一样;value是一个无符号整数,指定了信号量的初始值。

当一个进程打开一个命名信号量时,系统会记录进程与信号量之间的关联关系。sem_close()函数会终止这种关联关系(即关闭信号量),释放系统为该进程关联到该信号量之上的所有资源,并递减引用该信号量的进程数:

1
2
#include<semaphore.h>
int sem_close(sem_t* sem); //返回0表示成功,-1表示失败

打开的命名信号量在进程终止或进程执行了一个exec()时会自动被关闭。

关闭一个信号量并不会删除这个信号量,而要删除信号量则需要使用sem_unlink()name标识的信号量将会在所有进程都使用完这个信号量时就被销毁。

1
2
#include<semaphore.h>
int sem_unlink(const char* name); //返回0表示成功,-1表示失败

信号量操作

要等待一个信号量,可以使用下面的函数:

1
2
3
4
5
6
#include<semaphore.h>

int sem_wait(sem_t* sem); //如果信号量的值大于0,则函数立即返回,并将信号量的值减1;如果信号量等于0则会一直阻塞,直到信号量的值大于0
int sem_trywait(sem_t* sem); //非阻塞版本,如果递减操作无法被立即执行则返回EAGAIN错误
int sem_timedwait(sem_t* sem, const struct timespec* abs_timeout); //设定阻塞的时间限制,如果到达时间限制之后仍无法递减信号量,则返回ETIMEDOUT错误
//上面的函数返回0表示成功,返回-1表示失败

要发布一个信号量则可以使用sem_post()函数:

1
2
3
#include<semaphore.h>

int sem_post(sem_t* sem); //返回0表示成功,-1表示失败

sem_post()调用会将sem引用信号量的值加1。如果在sem_post调用之前信号量的值为0,并且其它某个进程或者线程因等待递减这个信号量而阻塞,则等待进程会被唤醒。哪个等待进程会被唤醒与系统的调度策略有关。

要获取信号量的当前值,则可以使用sem_getvalue()函数:

1
2
3
#include<semaphore.h>

int sem_getvalue(sem_t* sem, int* sval); //返回0表示成功,-1表示失败

这一函数调用会将sem引用的信号量的当前值通过sval指向的变量返回。如果一个或多个进程(或线程)当前正在阻塞以等待递减信号量值,那么sval中的返回值将取决于实现,在Linux系统中会返回0。

未命名信号量

未命名信号量(也被称为基于内存的信号量)是类型为sem_t并存储在应用程序分配的内存中的变量。通过将这个信号量放在由几个进程或线程共性的内存区域中就能够使这个信号量对这些进程或线程可用。

操作未命名信号量所使用的函数与操作命名信号量使用的函数是一样的,除此之外还需要使用sem_init()sem_destroy(sem)两个函数。

sem_init()使用value参数指定的值,来对sem指向的未命名信号量进行初始化:

1
2
3
#include<semaphore.h>

int sem_init(sem_t* sem, int pshared, unsigned int value); //返回0表示成功,-1表示失败

pshared参数表明信号量是在线程还是进程间共享。如果pshared等于0,那么信号量将会在调用进程中的线程间进行共享。在这种情况下,sem通常被指定成一个全局变量的地址或分配在堆上的一个变量的地址。线程共享的信号量具备进程持久性,它在进程终止时会被销毁;如果pshared不等于0,那么信号量将会在进程间共享。在这种情况下,sem必须是共享内存区域(一个POSIX 共享内存对象、一个使用mmap()创建的共享映射、或一个System V共享内存段)中的某个位置的地址。

要销毁未命名信号量,则需要使用sem_destroy()函数:

1
2
3
#include<semaphore.h>

int sem_destroy(sem_t* sem); //返回0表示成功,-1表示失败

这一函数将会销毁信号量sem,其中sem必须是一个之前使用sem_init()进行初始化的未命名信号量。只有不存在进程或者线程在等待一个信号量时,才能安全销毁这个信号量。

共享内存

概述

POSIX共享内存能够让无关进程共享一个映射区域而无需创建一个相应的映射文件。要使用 POSIX 共享内存对象需要完成下列任务。

  1. 使用shm_open()函数打开一个与指定的名字对应的对象。shm_open()函数与open()系统调用类似,它会创建一个新共享对象或打开一个既有对象。shm_open()会返回一个引用该对象的文件描述符。
  2. 将上一步中获得的文件描述符传入mmap()调用并在其flags参数中指定MAP_SHARED。这会将共享内存对象映射进进程的虚拟地址空间。与mmap()的其他用法一样,一旦映射了对象之后就能够关闭该文件描述符而不会影响到这个映射。

创建共享内存对象

创建一个共享内存对象的方法如下:

1
2
3
4
5
#include<fcntl.h>
#include<sys/stat.h>
#include<sys/mman.h>

int shm_open(const char* name, int oflag, mode_t mode); //成功则返回文件描述符,失败返回-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
2
3
#include<sys/mman.h>

int shm_unlink(const char* name); //返回0表示成功,-1表示失败

shm_unlink()函数会删除通过name指定的共享内存对象。删除一个共享内存对象不会影响对象的既有映射(它会保持有效直到相应的进程调用munmap()或终止),但会阻止后续的shm_open()调用打开这个对象。一旦所有进程都解除映射这个对象,对象就会被删除,其中的内容会丢失。

SOCKET

简介

socket是一种IPC方法,它允许位于同一主机(计算机)或使用网络连接起来的不同主机上的应用程序之间交换数据。在一个典型的客户端/服务器场景中,应用程序使用socket进行通信的方式如下:

  • 各个应用程序创建一个socket
  • 服务器将自己的socket绑定到一个地址上,使得客户端可以定位到它的位置

socket存在于一个通信domain中,它确定了识别出一个socket的方法,并确定其通信范围。现代操作系统至少支持UNIX、IPv4和IPv6这三个domain。它们的区别如下图:

image-20210719145219281

每一个socket实现都至少提供了流和数据报这两种类型。流socket提供了一个可靠的双向字节流通信信道,可以保证发送的数据完整地到达接收端,但是数据不存在消息边界的概念。它类似于使用一对允许在两个应用程序之间双向通信的管道,但是socket允许在网络上进行通信。流socket的正常工作需要一对相互连接的socket,因此它也常常被称为面向连接的。

而数据报socket则允许数据以数据报的消息形式进行交换,因此数据的消息边界被保留下来。但是这种方式的数据传输是不可靠的,消息到达可能是无序的、重复的甚至无法到达。它属于无连接的socket。

系统调用

要创建一个socket,可以使用socket()系统调用:

1
2
3
#include<sys/socket.h>

int socket(int domain, int type, int protocol); //成功则返回一个文件描述符,失败返回-1

其中domain参数指定了socket的通信domain;type参数指定了socket类型,创建流socket时为SOCK_STREAM,创建数据报socket时被设为SOCK_DGRAM;protocol参数通常被设置为0。

如果要将一个socket绑定到一个地址上,则可以使用bind()系统调用:

1
2
3
#include<sys/socket.h>

int bind(int sockfd, const struct sockaddr* addr, socklen_t addrlen); //返回0表示成功,-1表示失败

其中,sockfd参数是一个socket()调用获得的文件描述符,addr参数为一个指针,指向socket绑定的地址的结构,这个数据结构类型取决于socket domain。addrlen参数则指定了地址结构数据的大小。一般来说,会将一个服务器的socket绑定到一个众所周知的地址,这个地址是固定的,且客户端应用程序提前知道。

sockaddr数据结构的定义如下:

1
2
3
4
struct sockaddr{
sa_family_t sa_family; //即AF_*,代表socket的domain
char* sa_data; //socket地址
}

要关闭一个socket,则可以使用close()函数,这会将双向通信通道的两端全部关闭。如果要实现更精确的控制,则可以使用shutdown()函数:

1
2
3
#include<sys/socket.h>

int shutdown(int sockfd, int how);

其中,how参数的值可以为如下几种:

  • SHUT_RD:关闭连接的读端,之后的读操作将会返回文件结尾
  • SHUT_WR:关闭连接的写端,后续再执行写操作则会产生SIGPIPE信号以及EPIPE错误
  • SHUT_RDWR:将连接的读写端全部关闭

需要注意的是,shutdown()并不会关闭文件描述符,必须调用close()关闭。

流socket

流socket的运作原理如下:

  1. socket()系统调用将会创建一个socket,这相当于安装一个电话。为了使得两个应用程序能够通信,每个应用程序都必须要创建一个socket。
  2. 一个应用程序在进行通信之前必须要将其socket连接到另一个应用程序的socket上。两个socket的连接过程如下:
    1. 一个应用程序调用bind()将socket绑定到一个地址上,然后调用listen()通知内核它接受接入连接的意愿。
    2. 其它应用程序通过调用connect()建立连接,同时指定需要连接的socket地址
    3. 调用listen()的应用程序使用accept()接受连接。如果在对等应用程序调用connect()之前执行了accept(),那么accept()操作会阻塞。
  3. 一旦建立了一个连接之后,就可以在应用程序之间进行双向数据传输,直到其中一个使用close()关闭连接为止。通信是通过传统的read()write()系统调用,或通过一些提供了额外功能的socket特定系统调用(如send()recv())来完成的。

流socket通常可以分为主动和被动两种。在默认情况下,使用socket()创建的socket是主动的。一个主动的socket可用在connect()调用中,来建立一个到被动socket的连接。而一个被动socket(也被称为监听socket)是一个通过调用listen()以被标记成允许接入连接的socket。在大多数使用流socket的应用程序中,服务器会执行被动式打开,而客户端会执行主动式打开。

这一过程中涉及到的系统调用包括:

1
2
3
4
5
#include<sys/socket.h>

int listen(int sockfd, int backlog); //返回0表示成功,-1表示失败
int accept(int sockfd, struct sockaddr* addr, socklen_t* addrlen); //成功则返回文件描述符,失败返回-1
int connect(int sockfd, const struct sockaddr* addr, socklen_t addrlen); //返回0表示成功,-1表示失败

数据报socket

数据报socket的运作原理如下:

  1. 所有需要发送和接收数据报的应用程序都需要使用socket()创建一个数据报socket。这可以理解为创建两个邮箱。
  2. 为允许另一个应用程序发送其数据报,一个应用程序需要使用bind()将其socket绑定到一个众所周知的地址上。一般来讲,一个服务器会将其socket绑定到一个众所周知的地址上,而一个客户端会通过向该地址发送一个数据报来发起通信。
  3. 要发送一个数据报,一个应用程序需要调用sendto(),它接收的其中一个参数是数据报发送到的socket的地址。
  4. 为接收一个数据报,一个应用程序需要调用recvfrom(),它在没有数据报到达时会阻塞。由于recvfrom()允许获取发送者的地址,因此可以在需要的时候发送一个响应。
  5. 当不再需要socket时,应用程序需要使用close()关闭socket。

其中涉及到的系统调用有:

1
2
3
4
#include<sys/socket.h>

ssize_t recvfrom(int sockfd, void* buffer, size_t length, int flags, struct sockaddr* src_addr, socklen_t* addrlen); //成功则返回接收的字节数,如果返回0则代表EOF,失败返回-1
ssize_t sendto(int sockfd, const void* buffer, size_t length, int flags, const struct sockaddr* dest_addr, socklen_t addrlen); //成功返回发送的字节数,失败返回-1

其中flags是一个位掩码,控制socket特定的I/O特性。对于recvfrom函数来说,如果不关心发送者的地址,那么可以将src_addraddrlen都指定为NULL,此时等价于使用recv()来接收一个数据报。

不管length的参数值是什么,recvfrom()只会从一个数据报socket中读取一条消息。如果消息的大小超过了length字节,那么消息会被静默地截断为length字节。

UNIX domain

在UNIX domain中,socket地址使用路径名来表示,地址结构的定义如下:

1
2
3
4
struct sockaddr_un{
sa_family_t sun_family; //总是等于AF_UNIX
char sun_path[108]; //以NULL结尾的字符串
}

关于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
2
3
#include<sys/socket.h>

int socketpair(int domain, int type, int protocol, int sockfd[2]); //返回0表示成功,-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
2
3
4
5
6
7
8
9
10
struct sockaddr_in{
sa_family_t sin_family; //总为AF_INET
in_port_t sin_port; //网络字节序的端口号
struct in_addr sin_addr; //地址
unsigned char __pad[X]; //
}

struct in_addr{
in_addr_t s_addr; //无符号的32位整数,代表IPv4的4字节地址,需要转为网络字节序
}
1
2
3
4
5
6
7
8
9
10
11
struct sockaddr_in6{
sa_family_t sin6_family; //总为AF_INET6
in_port_t sin6_port; //端口号
uint32_t sin6_flowinfo;
struct in6_addr sin6_addr; //地址
uint32_t sin6_scope_id;
}

struct in6_addr{
uint8_t s6_addr[16];
}

也可以使用通用的sockaddr_storage结构,这个数据结构的空间足以存储任意类型的socket地址:

1
2
3
4
5
6
#define __ss_aligntype uint32_t
struct sockaddr_storage{
sa_family_t ss_family;
__ss_aligntype __ss_align;
char __ss_padding[SS_PADSIZE];
}

由于IP地址在计算机中被存储为二进制形式,不方便阅读,下面两个函数可以方便将IPv4和IPv6地址的二进制形式和点分十进制表示法或者十六进制字符串表示法之间转换:

1
2
3
4
#include<arpa/inet.h>

int inet_pton(int domain, const char* src_str, void* addrptr);
const char* inet_ntop(int domain, const void* addrptr, char* dst_str, size_t len);

其中,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
2
3
#include<netinet/in.h>
#define INET_ADDRSTRLEN 16
#define INET6_ADDRSTRLEN 46

一个多字节的整数可能会以大端序或者小端序这两种不同的顺序来存储。在特定主机上使用的字节序称为主机字节序,它与硬件架构有关;而在网络中传递的顺序被称为网络字节序,它被规定为大端序。因此,在将整数存储进socket地址结构之前则需要将这些值转换成网络字节序,可以使用下面四个函数完成这一操作:

1
2
3
4
5
6
#include<arpa/inet.h>

uint16_t htons(uint16_t host_uint16);
unit32_t htonl(uint32_t host_uint32);
uint16_t ntohs(uint16_t net_uint16);
uint32_t ntohl(uint32_t net_uint32);

其它用法

recv()send()这一组系统调用可以在已连接的套接字上执行I/O操作,它们提供了专属于套接字的功能:

1
2
3
4
#include<sys/socket.h>

ssize_t recv(int sockfd, void* buffer, size_t length, int flags);
ssize_t send(int sockfd, const void* buffer, size_t length, int flags);

其中,前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
2
3
#include<sys/sendfile.h>

ssize_t sendfile(int out_fd, int in_fd, off_t* offset, size_t count); //调用成功返回实际传输字节数,失败返回-1

其中,in_fd代表输入文件描述符,out_fd代表输出文件描述符。要求out_fd必须指向一个套接字,而in_fd指向的文件可以进行mmap()操作。参数offset可以指向一个off_t类型的值,指定输入文件的偏移量,代表in_fd指向的文件从这一位置开始可以传输字节,此时数据传输不会修改in_fd的文件偏移量;也可以设置为NULL,此时会从当前的文件偏移量处开始传输,且传输时会更新文件偏移量。count参数指定了请求传输的字节数。

套接字选项能影响到套接字操作的多个功能。下面两个系统调用可以设定和获取套接字选项:

1
2
3
4
#include<sys/socket.h>

int getsocket(int sockfd, int level, int optname, void* optval, socklen_t* optlen);
int setsocket(int sockfd, int level, int optname, const void* optval, socklen_t optlen);

其中,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
2
3
4
#include<sys/time.h>
#include<sys/select.h>

int select(int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout); //成功则返回就绪态的文件描述符个数,超时返回0,失败则返回-1

其中,timeout参数设定select阻塞的时间上限,如果设置为NULL则代表一直阻塞;参数nfds通常设为比3个文件描述符集合中所包含的最大文件描述符号还要大1,这个参数让select()变得更有效率;readfdswritefdsexceptfds分别代表检测输入是否就绪,输出是否就绪以及异常情况是否发生的文件描述符集合,它们在select()调用之后会被修改,其中包含处于就绪态的文件描述符集合。

数据类型fd_set通常以位掩码的形式来实现,但是我们无需知道细节,关于文件描述符集合的操作通过如下四个宏来完成:

1
2
3
4
5
6
#include<sys/select.h>

void FD_ZERO(fd_set* fdset); //将fdset指向的集合初始化为空
void FD_SET(int fd, fd_set* fdset); //将文件描述符fd添加到fdset指向的集合
void FD_CLR(int fd, fd_set* fdset); //将文件描述符fd从fdset指向的集合中移除
int FD_ISSET(int fd, fd_set* fdset); //如果fd是fdset的元素,则返回1;否则返回0

如果select()返回一个正数,则代表至少一个文件描述符到达就绪态,此时需要使用FD_ISSET对三个文件描述符集合进行检查。

系统调用poll()执行的任务同select()很相似。两者间主要的区别在于我们要如何指定待检查的文件描述符,poll()系统调用提供一列文件描述符,并在每个文件描述符上标明我们感兴趣的事件。它的用法如下:

1
2
3
4
5
6
7
8
9
#include<poll.h>

int poll(struct pollfd fds[], nfds_t nfds, int timeout);//成功则返回就绪态的文件描述符个数,超时返回0,失败则返回-1

struct pollfd{
int fd; //文件描述符
short event; //位掩码,指定为描述符fd做检查的事件
short revents; //位掩码,函数返回时,它被设定为文件描述符上实际发生的事件
}

timeout参数决定了poll的阻塞行为,如果它的值为-1则会一直阻塞直到fds中列出的描述符有一个达到就绪态;如果为0则不会阻塞,仅仅是检查是否有处于就绪态的文件描述符;如果大于0则代表阻塞的时间上限(毫秒)。eventsfevents参数能够使用的位掩码如下:

image-20210720164004044

随着待检查的文件描述符数量的增加,select()poll()所占用的CPU时间也会随之增加。对于需要检查大量文件描述符的程序来说,这就产生了问题。而且内核并不会在每次调用成功之后记录下检查的文件描述符,当重复检查相同的文件描述符时会带来糟糕的性能延展性。

信号驱动I/O

在信号驱动I/O中,当文件描述符上可执行I/O操作时,进程请求内核为自己发送一个信号。之后进程就可以执行任何其他的任务,直到I/O 就绪为止,此时内核会发送信号给进程。要使用信号驱动I/O,程序需要按照如下步骤来执行。

  1. 为内核发送的通知信号安装一个信号处理例程。默认情况下,这个通知信号为SIGIO。
  2. 设定文件描述符的属主,也就是当文件描述符上可执行I/O时会接收到通知信号的进程或进程组。通常我们让调用进程成为属主。设定属主可通过fcntl()的F_SETOWN操作来完成
  3. 通过设定O_NONBLOCK标志使能非阻塞I/O。
  4. 通过打开O_ASYNC标志使能信号驱动I/O。这可以和上一步合并为一个操作,因为它们都需要用到fcntl()的F_SETFL 操作。
  5. 调用进程现在可以执行其他的任务。当I/O操作就绪时,内核为进程发送一个信号,然后调用在第1步中安装好的信号处理例程。
  6. 信号驱动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
2
3
4
5
#include<sys/epoll.h>

int epoll_create(int size); //成功返回文件描述符,-1表示失败
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* ev); //成功返回0,失败返回-1
int epoll_wait(int epfd, struct epoll_event* evlist, int maxevents, int timeout); //成功则返回就绪态的文件描述符个数,超时返回0,失败则返回-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
2
3
4
5
6
7
8
9
10
11
struct epoll_event{
uint32_t events; //位掩码,代表为待检查的文件描述符指定感兴趣的事件集合
epoll_data_t data; //指定传回给调用进程的信息
}

typedef union epoll_data{
void* ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;

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字段上的可以使用的位掩码值如下:

image-20210720171448762

随着被监视的文件描述符数量的上升,poll()select()的性能表现越来越差。与之相反,epoll的性能表现几乎不会降低。这是因为当通过epoll_ctl()指定了需要监视的文件描述符时,内核会在与打开的文件描述上下文相关联的列表中记录该描述符。之后每当执行I/O操作使得文件描述符成为就绪态时,内核就在epoll描述符的就绪列表中添加一个元素。之后的epoll_wait()调用从就绪列表中简单地取出这些元素。同时,在 epoll中我们使用epoll_ctl()在内核空间中建立一个数据结构,该数据结构会将待监视的文件描述符都记录下来。一旦这个数据结构建立完成,稍后每次调用epoll_wait()时就不需要再传递任何与文件描述符有关的信息给内核了,而调用返回的信息中只包含那些已经处于就绪态的描述符。