八、信号
57. 信号的基本特征
- 定义:信号是事件发生时对进程的通知机制,也可以把它称为软件中断
- 信号处理方式
- 忽略信号
- 大多数信号都可以使用这种方式进行处理,但 SIGKILL 和 SIGSTOP 绝对不能被忽略
- 如果忽略某些由硬件异常产生的信号,则进程的运行行为是未定义的
- 捕获信号
- 当信号到达进程后,执行预先绑定好的信号处理函数
- Linux 系统提供系统调用 signal() 用于注册信号处理函数
- 执行系统默认操作
- 进程不对该信号事件作出处理,而是交由系统进行处理,每一种信号都会有其对应的系统默认的处理方式
- 对大多数信号来说,系统默认的处理方式就是终止该进程
- 忽略信号
- 信号是异步的
- 产生信号的事件对进程而言是随机出现的,进程无法预测该事件产生的准确时间,进程不能通过简单地测试一个变量或使用系统调用来判断是否产生了一个信号,这就如同硬件中断事件,程序是无法得知中断事件产生的具体时间,只有当产生中断事件时才会告知程序,然后打断当前程序的正常执行流程、跳转去执行中断服务函数,这就是异步处理方式
- 信号本质上是 int 类型数字编号
- 这就好比硬件中断所对应的中断号。内核针对每个信号,都给其定义了一个唯一的整数编号,从数字 1 开始顺序展开。并且每一个信号都有其对应的名字(其实就是一个宏),信号名字与信号编号是一一对应关系,但是由于每个信号的实际编号随着系统的不同可能会不一样)
- 不存在编号为 0 的信号,信号编号是从 1 开始的
58. 信号产生方式
- 按键产生
- Ctrl + c → (2)SIGINT(终止/中断) “INT” ---- Interrupt
- Ctrl + z → (20)SIGTSTP(暂停/停止) “T” ---- Terminal 终端
- Ctrl + \ → (3)SIGQUIT(退出)
- 系统调用产生
- 如:kill、raise、abort
- 软件条件产生
- 如:定时器 alarm
- 使用 alarm() 函数可以设置一个定时器(闹钟),当定时器时间到,内核会向进程发送 SIGALRM 信号
- 硬件异常产生
- 除 0 操作 → (8)SIGFPE (浮点数例外) “F” -----float 浮点数
- 非法访问内存 → (11)SIGSEGV (段错误)
- 总线错误 → (7)SIGBUS
- 命令产生
- kill 命令:kill() 系统调用可将信号发送给指定的进程或进程组中的每一个进程
59. 信号的分类
59.1 可靠信号与不可靠信号
- Linux 信号机制基本上是从 UNIX 系统中继承过来的,早期 UNIX 系统中的信号机制存在问题:进程每次处理信号后,就将对信号的响应设置为系统默认操作
- 早期 UNIX 下的不可靠信号主要是指
- 进程可能对信号做出错误的反应
- 信号可能丢失(处理信号时又来了新的信号,则导致信号丢失)
- Linux 支持不可靠信号,但是对不可靠信号机制做了改进
- 在调用完信号处理函数后,不必重新调用 signal(),Linux 下的不可靠信号问题主要指的是信号可能丢失
- Linux 系统下,信号值小于 SIGRTMIN(34)的信号都是不可靠信号
- 新增加了一些信号 (SIGRTMIN(34)~ SIGRTMAX(64)),并把它们定义为可靠信号
- 编号 1~31 所对应的是不可靠信号,编号 34~64 对应的是可靠信号
- 可靠信号支持排队,不会丢失,可靠信号并没有一个具体对应的名字
$ kill -l 1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP 6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1 11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM 16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP 21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ 26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR 31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3 38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8 43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13 48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12 53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7 58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2 63) SIGRTMAX-1 64) SIGRTMAX
59.2 实时信号与非实时信号
- 实时信号与非实时信号其实是从时间关系上进行的分类,与可靠信号与不可靠信号相对应
- 非实时信号都不支持排队,都是不可靠信号
- 实时信号都支持排队,都是可靠信号
- 实时信号保证了发送的多个信号都能被接收,实时信号是 POSIX 标准的一部分,可用于应用进程
- 一般也把非实时信号(不可靠信号)称为标准信号
60. 常见信号与默认行为
- term 表示终止进程
- core 表示生成核心转储文件,核心转储文件可用于调试
- ignore 表示忽略信号
- cont 表示继续运行进程
- stop 表示停止进程(停止不等于终止,而是暂停)
61. signal() 和 sigaction()
- 系统调用 signal() 函数可将信号的处理方式设置为捕获信号、忽略信号以及系统默认操作
#include <signal.h> typedef void (*sig_t)(int); sig_t signal(int signum, sig_t handler);
- sigaction() 系统调用是设置信号处理方式的另一选择,推荐使用 sigaction() 函数。虽然 signal() 函数简单好用,而 sigaction() 更复杂,但 sigaction() 更具灵活性以及移植性
- sigaction() 允许单独获取信号的处理函数而不是设置
- 还可以设置各种属性对调用信号处理函数时的行为施以更加精准的控制
62. 信号集
- 信号集(signalset)用于表示多个信号(一组信号)的数据类型,信号集其实就是 sigset_t 类型数据结构
- Linux 内核的进程控制块 PCB 是一个结构体 task_struct,除了包含进程 id、状态、工作目录、用户 id、组 id 和文件描述符表,还包含了信号相关的信息,主要指:阻塞信号集和未决信号集
- 阻塞信号集 (信号屏蔽字、信号掩码)
- 本质是位图,用来记录信号的屏蔽状态。将某些信号加入集合,对他们设置屏蔽,当屏蔽某信号后,当再收到该信号时,该信号的处理将推后 (一直处于未决状态,直到解除屏蔽)
- 未决信号集
- 本质是位图,用来记录信号的处理状态。信号产生,未决信号集中描述该信号的位立刻翻转为 1,表示信号处于未决状态;当信号被处理则对应位翻转回 0,这一时刻往往非常短暂
- 信号产生后由于某些原因 (主要是阻塞) 不能抵达。这类信号的集合称之为未决信号集。在阻塞解除前,信号一直处于未决状态
- 阻塞信号集 (信号屏蔽字、信号掩码)
63. sigsuspend() 和 sigpending()
- 阻塞等待信号:更改进程的信号掩码可以阻塞所选择的信号,或解除对它们的阻塞,可以保护不希望由信号中断的关键代码段。如果希望对一个信号解除阻塞后,然后调用 pause() 以等待之前被阻塞的信号的传递,如何实现?
- 使用 sigsuspend() 系统调用将恢复信号掩码和 pause() 挂起进程这两个动作封装成一个原子操作
- sigsuspend() 会将进程的信号掩码设置为参数 mask 所指向的信号集,然后挂起进程,直到捕获到信号被唤醒(如果捕获的信号是 mask 信号集中的成员,将不会唤醒、继续挂起),并从信号处理函数返回,一旦从信号处理函数返回,sigsuspend() 会将进程的信号掩码恢复成调用前的值
- 实时信号:如果进程当前正在执行信号处理函数,在处理信号期间接收到了新的信号,如果该信号是信号掩码中的成员,那么内核会将其阻塞,将该信号添加到进程的等待信号集(等待被处理的信号)中,为确定进程中处于等待状态的是哪些信号,可以使用 sigpending() 函数获取
九、高级 I/O
64. 非阻塞 I/O
- 阻塞就是进入了休眠状态,交出了 CPU 控制权
- 阻塞 I/O 就是对文件的 I/O 操作(读写操作)是阻塞式的,非阻塞 I/O 就是对文件的 I/O 操作是非阻塞的
- 阻塞式 I/O:对于某些文件类型(读管道文件、网络设备文件和字符设备文件),当对文件进行读操作时,如果数据未准备好或文件当前无数据可读,那么读操作可能会使调用者阻塞,直到有数据可读时才会被唤醒
- 非阻塞式 I/O:即使没有数据可读也不会被阻塞,而是会立马返回错误
- 普通文件不管读写多少个字节数据,read() 或 write() 一定会在有限的时间内返回,所以普通文件总是以非阻塞的方式进行 I/O 操作,但管道文件、设备文件等,既可使用阻塞式 I/O 操作,也可使用非阻塞式 I/O 进行操作
65. 阻塞 I/O 的优缺点
- 当对文件进行读取操作时,如果文件当前无数据可读
- 阻塞式 I/O 会将调用者应用程序挂起、进入休眠阻塞状态,直到有数据可读时才会解除阻塞,阻塞式 I/O 的困境:无法实现并发读取(同时读取),主要原因在于阻塞
- 而对于非阻塞 I/O,应用程序不会被挂起,而是会立即返回,它要么一直轮训等待,直到数据可读,要么直接放弃
- 优点
- 能提升 CPU 的处理效率,当自身条件不满足时,进入阻塞状态并交出 CPU 资源,将 CPU 资源让给别人使用
- 而非阻塞式则是抓紧利用 CPU 资源(如不断地去轮训),这样就会导致该程序占用非常高的 CPU 使用率
66. I/O 多路复用
虽然使用非阻塞式 I/O 解决了阻塞式 I/O 情况下并发读取文件所出现的问题,但使得程序的 CPU 占用率特别高,为了解决这个问题,就要用到 I/O 多路复用方法
- I/O 多路复用通过一种机制,可以监视多个文件描述符,一旦某个文件描述符(也就是某个文件)可以执行 I/O 操作时,能够通知应用程序进行相应的读写操作
- I/O 多路复用技术是为了解决:在并发式 I/O 场景中使进程或线程不阻塞于某个特定的 I/O 系统调用
- I/O 多路复用一般用于并发非阻塞 I/O(多路非阻塞 I/O),如:既要读取鼠标、又要读取键盘
- I/O 多路复用特征:外部阻塞式,内部监视多路 I/O
67. select() 和 poll()
采用两个功能几乎相同的系统调用来执行 I/O 多路复用操作,分别是 select() 和 poll()
-
调用 select() 函数将阻塞直到有以下事情发生
- 1、readfds、writefds 或 exceptfds 指定的文件描述符中至少有一个成为就绪态
- readfds 是用来检测读是否就绪(是否可读)的文件描述符集合
- writefds 是用来检测写是否就绪(是否可写)的文件描述符集合
- exceptfds 是用来检测异常情况是否发生的文件描述符集合
- 如果这三个参数都设置为 NULL,则可以将 select() 当做为一个类似于 sleep() 休眠的函数来使用,通过 select() 函数的最后一个参数 timeout 来设置休眠时间
- 2、该调用被信号处理函数中断
- 3、参数 timeout 中指定的时间上限已经超时
- 1、readfds、writefds 或 exceptfds 指定的文件描述符中至少有一个成为就绪态
-
在 poll() 函数中,需要构造一个 struct pollfd 类型的数组,每个数组元素指定一个文件描述符以及对该文件描述符所关心的条件(数据可读、可写或异常情况)
#include <poll.h> int poll(struct pollfd *fds, nfds_t nfds, int timeout); // 调用者初始化 events 来指定需要为文件描述符 fd 做检查的事件 // 当 poll() 函数返回时,revents 变量由 poll() 函数内部进行设置,用于说明文件描述符 fd 发生了哪些事件 struct pollfd { int fd; /* 文件描述符 */ short events; /* 位掩码:requested events */ short revents; /* 位掩码:returned events */ };
-
使用 select() 或 poll() 时需要注意一个问题
- 当监测到某一个或多个文件描述符成为就绪态(可读或写)时,需要执行相应的 I/O 操作以清除该状态,否则该状态会一直存在,那么下一次调用 select() 或 poll() 时,文件描述符已经处于就绪态了,将直接返回
68. 异步 I/O
- 在 I/O 多路复用中,进程通过系统调用 select() 或 poll() 来主动查询文件描述符上是否可以执行 I/O 操作
- 而异步 I/O 中,当文件描述符上可执行 I/O 操作时,进程可以请求内核为自己发送一个信号,之后进程就可执行任何其它的任务直到文件描述符可执行 I/O 操作为止,此时内核会发送信号给进程,异步 I/O 通常也称信号驱动 I/O
- 异步 I/O 执行步骤
- 通过指定 O_NONBLOCK 标志使能非阻塞 I/O
- 通过指定 O_ASYNC 标志使能异步 I/O
- 设置异步 I/O 事件的接收进程
- 也就是当文件描述符上可执行 I/O 操作时会发送信号通知该进程,通常将调用进程设置为异步 I/O 事件的接收进程
- 为内核发送的通知信号注册一个信号处理函数
- 默认情况下,异步 I/O 的通知信号是 SIGIO
69. 存储映射 I/O
-
存储映射 I/O 是一种基于内存区域的高级 I/O 操作,它能将一个文件映射到进程地址空间中的一块内存区域中
- 当从这段内存中读数据时,就相当于读文件中的数据(read 操作)
- 将数据写入这段内存时,则相当于将数据直接写入文件中(write 操作)
- 这样就可以在不使用基本 I/O 操作函数 read() 和 write() 的情况下执行 I/O 操作
-
普通 I/O 与存储映射 I/O 比较
- 普通 I/O 方式的缺点
- 普通 I/O 方式一般是通过调用 read() 和 write() 函数实现对文件读写,使用 read() 和 write() 读写文件时,函数经过层层调用才能最终操作文件,效率会比较低,使用标准 I/O(库函数 fread()、fwrite())同样如此(标准 I/O 就是对普通 I/O 的一种封装)
- 只有当数据量比较大时,效率的影响才会比较明显,如果数据量比较小则影响并不大,使用普通的 I/O 方式还是非常方便的
- 存储映射 I/O 的优点
- 存储映射 I/O 的实质其实是共享
- 使用存储映射 I/O 减少了数据的复制操作,效率比普通 I/O 高
- 存储映射 I/O 的不足
- 所映射的文件只能是固定大小,因为文件所映射的区域已经在调用 mmap() 函数时通过 length 参数指定
- 文件映射的内存区域的大小必须是系统页大小的整数倍
- 使用存储映射 I/O 在进行大数据量操作时比较有效,对于少量数据则使用普通 I/O 方式更方便
- 普通 I/O 方式的缺点
70. 文件锁
多个进程同时操作同一文件,很容易导致文件中的数据发生混乱,因为多个进程对文件进行 I/O 操作时,容易产生竞争状态,导致文件中的内容与预想的不一致
-
进程有时需要确保只有它自己能够对某一文件进行 I/O 操作,在这段时间内不允许其它进程对该文件进行 I/O 操作
- Linux 系统提供了文件锁机制来实现:文件锁是用于对共享资源的访问进行保护的机制,对文件上锁,来避免多个进程同时操作同一文件时产生竞争状态
-
文件锁分类
- 建议性锁
- 本质上是一种协议,程序访问文件前先对文件上锁,上锁成功后再访问文件
在文件没有上锁的情况下直接访问文件也可以实现,但这样的话建议性锁就没有起作用
- 本质上是一种协议,程序访问文件前先对文件上锁,上锁成功后再访问文件
- 强制性锁
- 如果进程对文件上了强制性锁,其它进程在没有获取到文件锁的情况下是无法对文件进行访问的
- 原因:强制性锁会让内核检查每一个 I/O 操作(如 read()、write()),验证调用进程是否是该文件锁的拥有者,如果不是将无法访问文件
- 采取强制性锁对性能的影响很大,每次进行读写操作都必须检查文件锁
- 建议性锁
-
flock() 加锁
- 使用 flock() 函数可以对文件加锁或者解锁,但只能产生建议性锁
- 对于 flock(),同一个文件不会同时具有共享锁和互斥锁
-
fcntl() 加锁
- flock() 仅支持对整个文件进行加锁/ 解锁,而 fcntl() 可以对文件的某个区域(某部分内容)进行加锁/解锁 ,可以精确到某一个字节数据
- flock() 仅支持建议性锁类型,而 fcntl() 可支持建议性锁和强制性锁两种类型,但一般不建议使用强制性锁
-
lockf() 函数加锁
- lockf() 函数是一个库函数,其内部基于 fcntl() 实现,所以 lockf() 是对 fcntl 锁的一种封装