This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,429 @@
<audio id="audio" title="36 | 进程间通信:遇到大项目需要项目组之间的合作才行" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cd/86/cd5106444237bc58781dbadc29236586.mp3"></audio>
前面咱们接项目的时候,主要强调项目之间的隔离性。这是因为,我们刚开始接的都是小项目。随着我们接的项目越来越多,就难免遇到大项目,这就需要多个项目组进行合作才能完成。
两个项目组应该通过什么样的方式进行沟通与合作呢?作为老板,你应该如何设计整个流程呢?
## 管道模型
好在有这么多成熟的项目管理流程可以参考。最最传统的模型就是软件开发的**瀑布模型**Waterfall Model。所谓的瀑布模型其实就是将整个软件开发过程分成多个阶段往往是上一个阶段完全做完才将输出结果交给下一个阶段。就像下面这张图展示的一样。
<img src="https://static001.geekbang.org/resource/image/ed/c9/ed1fd2ede7a8fef5508c877e722345c9.png" alt="">
这种模型类似进程间通信的**管道模型**。还记得咱们最初学Linux命令的时候有下面这样一行命令
```
ps -ef | grep 关键字 | awk '{print $2}' | xargs kill -9
```
这里面的竖线“|”就是一个管道。它会将前一个命令的输出,作为后一个命令的输入。从管道的这个名称可以看出来,管道是一种单向传输数据的机制,它其实是一段缓存,里面的数据只能从一端写入,从另一端读出。如果想互相通信,我们需要创建两个管道才行。
管道分为两种类型,“|” 表示的管道称为**匿名管道**,意思就是这个类型的管道没有名字,用完了就销毁了。就像上面那个命令里面的一样,竖线代表的管道随着命令的执行自动创建、自动销毁。用户甚至都不知道自己在用管道这种技术,就已经解决了问题。所以这也是面试题里面经常会问的,到时候千万别说这是竖线,而要回答背后的机制,管道。
另外一种类型是**命名管道**。这个类型的管道需要通过mkfifo命令显式地创建。
```
mkfifo hello
```
hello就是这个管道的名称。管道以文件的形式存在这也符合Linux里面一切皆文件的原则。这个时候我们ls一下可以看到这个文件的类型是p就是pipe的意思。
```
# ls -l
prw-r--r-- 1 root root 0 May 21 23:29 hello
```
接下来,我们可以往管道里面写入东西。例如,写入一个字符串。
```
# echo &quot;hello world&quot; &gt; hello
```
这个时候,管道里面的内容没有被读出,这个命令就是停在这里的,这说明当一个项目组要把它的输出交接给另一个项目组做输入,当没有交接完毕的时候,前一个项目组是不能撒手不管的。
这个时候,我们就需要重新连接一个终端。在终端中,用下面的命令读取管道里面的内容:
```
# cat &lt; hello
hello world
```
一方面我们能够看到管道里面的内容被读取出来打印到了终端上另一方面echo那个命令正常退出了也即交接完毕前一个项目组就完成了使命可以解散了。
我们可以看出,瀑布模型的开发流程效率比较低下,因为团队之间无法频繁地沟通。而且,管道的使用模式,也不适合进程间频繁地交换数据。
于是,我们还得想其他的办法,例如我们是不是可以借鉴传统外企的沟通方式——邮件。邮件有一定的格式,例如抬头,正文,附件等,发送邮件可以建立收件人列表,所有在这个列表中的人,都可以反复地在此邮件基础上回复,达到频繁沟通的目的。
## 消息队列模型
<img src="https://static001.geekbang.org/resource/image/ac/a4/ac6ad6c9e7e3831f6d813113ae1c5ba4.png" alt="">
这种模型类似进程间通信的消息队列模型。和管道将信息一股脑儿地从一个进程,倒给另一个进程不同,消息队列有点儿像邮件,发送数据时,会分成一个一个独立的数据单元,也就是消息体,每个消息体都是固定大小的存储块,在字节流上不连续。
这个消息结构的定义我写在下面了。这里面的类型type和正文text没有强制规定只要消息的发送方和接收方约定好即可。
```
struct msg_buffer {
long mtype;
char mtext[1024];
};
```
接下来,我们需要创建一个消息队列,使用**msgget函数**。这个函数需要有一个参数key这是消息队列的唯一标识应该是唯一的。如何保持唯一性呢这个还是和文件关联。
我们可以指定一个文件ftok会根据这个文件的inode生成一个近乎唯一的key。只要在这个消息队列的生命周期内这个文件不要被删除就可以了。只要不删除无论什么时刻再调用ftok也会得到同样的key。这种key的使用方式在这一章会经常遇到这是因为它们都属于System V IPC进程间通信机制体系中。
```
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;sys/msg.h&gt;
int main() {
int messagequeueid;
key_t key;
if((key = ftok(&quot;/root/messagequeue/messagequeuekey&quot;, 1024)) &lt; 0)
{
perror(&quot;ftok error&quot;);
exit(1);
}
printf(&quot;Message Queue key: %d.\n&quot;, key);
if ((messagequeueid = msgget(key, IPC_CREAT|0777)) == -1)
{
perror(&quot;msgget error&quot;);
exit(1);
}
printf(&quot;Message queue id: %d.\n&quot;, messagequeueid);
}
```
在运行上面这个程序之前我们先使用命令touch messagequeuekey创建一个文件然后多次执行的结果就会像下面这样
```
# ./a.out
Message Queue key: 92536.
Message queue id: 32768.
```
System V IPC体系有一个统一的命令行工具ipcmkipcs和ipcrm用于创建、查看和删除IPC对象。
例如ipcs -q就能看到上面我们创建的消息队列对象。
```
# ipcs -q
------ Message Queues --------
key msqid owner perms used-bytes messages
0x00016978 32768 root 777 0 0
```
接下来,我们来看如何发送信息。发送消息主要调用**msgsnd函数**。第一个参数是message queue的id第二个参数是消息的结构体第三个参数是消息的长度最后一个参数是flag。这里IPC_NOWAIT表示发送的时候不阻塞直接返回。
下面的这段程序getopt_long、do-while循环以及switch是用来解析命令行参数的。命令行参数的格式定义在long_options里面。每一项的第一个成员“id”“type”“message”是参数选项的全称第二个成员都为1表示参数选项后面要跟参数最后一个成员it'm是参数选项的简称。
```
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;sys/msg.h&gt;
#include &lt;getopt.h&gt;
#include &lt;string.h&gt;
struct msg_buffer {
long mtype;
char mtext[1024];
};
int main(int argc, char *argv[]) {
int next_option;
const char* const short_options = &quot;i:t:m:&quot;;
const struct option long_options[] = {
{ &quot;id&quot;, 1, NULL, 'i'},
{ &quot;type&quot;, 1, NULL, 't'},
{ &quot;message&quot;, 1, NULL, 'm'},
{ NULL, 0, NULL, 0 }
};
int messagequeueid = -1;
struct msg_buffer buffer;
buffer.mtype = -1;
int len = -1;
char * message = NULL;
do {
next_option = getopt_long (argc, argv, short_options, long_options, NULL);
switch (next_option)
{
case 'i':
messagequeueid = atoi(optarg);
break;
case 't':
buffer.mtype = atol(optarg);
break;
case 'm':
message = optarg;
len = strlen(message) + 1;
if (len &gt; 1024) {
perror(&quot;message too long.&quot;);
exit(1);
}
memcpy(buffer.mtext, message, len);
break;
default:
break;
}
}while(next_option != -1);
if(messagequeueid != -1 &amp;&amp; buffer.mtype != -1 &amp;&amp; len != -1 &amp;&amp; message != NULL){
if(msgsnd(messagequeueid, &amp;buffer, len, IPC_NOWAIT) == -1){
perror(&quot;fail to send message.&quot;);
exit(1);
}
} else {
perror(&quot;arguments error&quot;);
}
return 0;
}
```
接下来,我们可以编译并运行这个发送程序。
```
gcc -o send sendmessage.c
./send -i 32768 -t 123 -m &quot;hello world&quot;
```
接下来,我们再来看如何收消息。收消息主要调用**msgrcv函数**第一个参数是message queue的id第二个参数是消息的结构体第三个参数是可接受的最大长度第四个参数是消息类型,最后一个参数是flag这里IPC_NOWAIT表示接收的时候不阻塞直接返回。
```
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;sys/msg.h&gt;
#include &lt;getopt.h&gt;
#include &lt;string.h&gt;
struct msg_buffer {
long mtype;
char mtext[1024];
};
int main(int argc, char *argv[]) {
int next_option;
const char* const short_options = &quot;i:t:&quot;;
const struct option long_options[] = {
{ &quot;id&quot;, 1, NULL, 'i'},
{ &quot;type&quot;, 1, NULL, 't'},
{ NULL, 0, NULL, 0 }
};
int messagequeueid = -1;
struct msg_buffer buffer;
long type = -1;
do {
next_option = getopt_long (argc, argv, short_options, long_options, NULL);
switch (next_option)
{
case 'i':
messagequeueid = atoi(optarg);
break;
case 't':
type = atol(optarg);
break;
default:
break;
}
}while(next_option != -1);
if(messagequeueid != -1 &amp;&amp; type != -1){
if(msgrcv(messagequeueid, &amp;buffer, 1024, type, IPC_NOWAIT) == -1){
perror(&quot;fail to recv message.&quot;);
exit(1);
}
printf(&quot;received message type : %d, text: %s.&quot;, buffer.mtype, buffer.mtext);
} else {
perror(&quot;arguments error&quot;);
}
return 0;
}
```
接下来,我们可以编译并运行这个发送程序。可以看到,如果有消息,可以正确地读到消息;如果没有,则返回没有消息。
```
# ./recv -i 32768 -t 123
received message type : 123, text: hello world.
# ./recv -i 32768 -t 123
fail to recv message.: No message of desired type
```
有了消息这种模型,两个进程之间的通信就像咱们平时发邮件一样,你来一封,我回一封,可以频繁沟通了。
## 共享内存模型
<img src="https://static001.geekbang.org/resource/image/df/38/df910e4383885b1aceaafb52b9bb5638.png" alt="">
但是有时候,项目组之间的沟通需要特别紧密,而且要分享一些比较大的数据。如果使用邮件,就发现,一方面邮件的来去不及时;另外一方面,附件大小也有限制,所以,这个时候,我们经常采取的方式就是,把两个项目组在需要合作的期间,拉到一个会议室进行合作开发,这样大家可以直接交流文档呀,架构图呀,直接在白板上画或者直接扔给对方,就可以直接看到。
可以看出来,共享会议室这种模型,类似进程间通信的**共享内存模型**。前面咱们讲内存管理的时候知道每个进程都有自己独立的虚拟内存空间不同的进程的虚拟内存空间映射到不同的物理内存中去。这个进程访问A地址和另一个进程访问A地址其实访问的是不同的物理内存地址对于数据的增删查改互不影响。
但是,咱们是不是可以变通一下,拿出一块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去。
共享内存也是System V IPC进程间通信机制体系中的所以从它使用流程可以看到熟悉的面孔。
我们可以创建一个共享内存调用shmget。在这个体系中创建一个IPC对象都是xxxget这里面第一个参数是key和msgget里面的key一样都是唯一定位一个共享内存对象也可以通过关联文件的方式实现唯一性。第二个参数是共享内存的大小。第三个参数如果是IPC_CREAT同样表示创建一个新的。
```
int shmget(key_t key, size_t size, int flag);
```
创建完毕之后我们可以通过ipcs命令查看这个共享内存。
```
#ipcs ­­--shmems
------ Shared Memory Segments ------ ­­­­­­­­
key shmid owner perms bytes nattch status
0x00000000 19398656 marc 600 1048576 2 dest
```
接下来如果一个进程想要访问这一段共享内存需要将这个内存加载到自己的虚拟地址空间的某个位置通过shmat函数就是attach的意思。其中addr就是要指定attach到这个地方。但是这个地址的设定难度比较大除非对于内存布局非常熟悉否则可能会attach到一个非法地址。所以通常的做法是将addr设为NULL让内核选一个合适的地址。返回值就是真正被attach的地方。
```
void *shmat(int shm_id, const void *addr, int flag);
```
如果共享内存使用完毕可以通过shmdt解除绑定然后通过shmctl将cmd设置为IPC_RMID从而删除这个共享内存对象。
```
int shmdt(void *addr);
int shmctl(int shm_id, int cmd, struct shmid_ds *buf);
```
## 信号量
这里你是不是有一个疑问如果两个进程attach同一个共享内存大家都往里面写东西很有可能就冲突了。例如两个进程都同时写一个地址那先写的那个进程会发现内容被别人覆盖了。
所以这里就需要一种保护机制使得同一个共享的资源同时只能被一个进程访问。在System V IPC进程间通信机制体系中早就想好了应对办法就是信号量Semaphore。因此信号量和共享内存往往要配合使用。
信号量其实是一个计数器,主要用于实现进程间的互斥与同步,而不是用于存储进程间通信数据。
我们可以将信号量初始化为一个数值,来代表某种资源的总体数量。对于信号量来讲,会定义两种原子操作,一个是**P操作**,我们称为**申请资源操作**。这个操作会申请将信号量的数值减去N表示这些数量被他申请使用了其他人不能用了。另一个是**V操作**,我们称为**归还资源操作**这个操作会申请将信号量加上M表示这些数量已经还给信号量了其他人可以使用了。
例如你有100元钱就可以将信号量设置为100。其中A向你借80元就会调用P操作申请减去80。如果同时B向你借50元但是B的P操作比A晚那就没有办法只好等待A归还钱的时候B的P操作才能成功。之后A调用V操作申请加上30元也就是还给你30元这个时候信号量有50元了这时候B的P操作才能成功才能借走这50元。
所谓**原子操作**Atomic Operation就是任何一块钱都只能通过P操作借给一个人不能同时借给两个人。也就是说当A的P操作借80和B的P操作借50几乎同时到达的时候不能因为大家都看到账户里有100就都成功必须分个先来后到。
如果想创建一个信号量我们可以通过semget函数。看又是xxxget第一个参数key也是类似的第二个参数num_sems不是指资源的数量而是表示可以创建多少个信号量形成一组信号量也就是说如果你有多种资源需要管理可以创建一个信号量组。
```
int semget(key_t key, int num_sems, int sem_flags);
```
接下来我们需要初始化信号量的总的资源数量。通过semctl函数第一个参数semid是这个信号量组的id第二个参数semnum才是在这个信号量组中某个信号量的id第三个参数是命令如果是初始化则用SETVAL第四个参数是一个union。如果初始化应该用里面的val设置资源总量。
```
int semctl(int semid, int semnum, int cmd, union semun args);
union semun
{
int val;
struct semid_ds *buf;
unsigned short int *array;
struct seminfo *__buf;
};
```
无论是P操作还是V操作我们统一用semop函数。第一个参数还是信号量组的id一次可以操作多个信号量。第三个参数numops就是有多少个操作第二个参数将这些操作放在一个数组中。
数组的每一项是一个struct sembuf里面的第一个成员是这个操作的对象是哪个信号量。
第二个成员就是要对这个信号量做多少改变。如果sem_op &lt; 0就请求sem_op的绝对值的资源。如果相应的资源数可以满足请求则将该信号量的值减去sem_op的绝对值函数成功返回。
当相应的资源数不能满足请求时就要看sem_flg了。如果把sem_flg设置为IPC_NOWAIT也就是没有资源也不等待则semop函数出错返回EAGAIN。如果sem_flg 没有指定IPC_NOWAIT则进程挂起直到当相应的资源数可以满足请求。若sem_op &gt; 0表示进程归还相应的资源数将 sem_op 的值加到信号量的值上。如果有进程正在休眠等待此信号量,则唤醒它们。
```
int semop(int semid, struct sembuf semoparray[], size_t numops);
struct sembuf
{
short sem_num; // 信号量组中对应的序号0sem_nums-1
short sem_op; // 信号量值在一次操作中的改变量
short sem_flg; // IPC_NOWAIT, SEM_UNDO
}
```
信号量和共享内存都比较复杂,两者还要结合起来用,就更加复杂,它们内核的机制就更加复杂。这一节我们先不讲,放到本章的最后一节重点讲解。
## 信号
上面讲的进程间通信的方式,都是常规状态下的工作模式,对应到咱们平时的工作交接,收发邮件、联合开发等,其实还有一种异常情况下的工作模式。
例如出现线上系统故障这个时候什么流程都来不及了不可能发邮件也来不及开会所有的架构师、开发、运维都要被通知紧急出动。所以7乘24小时不间断执行的系统都需要有告警系统一旦出事情就要通知到人哪怕是半夜也要电话叫起来处理故障。
对应到操作系统中就是信号。信号没有特别复杂的数据结构就是用一个代号一样的数字。Linux提供了几十种信号分别代表不同的意义。信号之间依靠它们的值来区分。这就像咱们看警匪片对于紧急的行动都是说“1号作战任务”开始执行警察就开始行动了。情况紧急不能啰里啰嗦了。
信号可以在任何时候发送给某一进程,进程需要为这个信号配置信号处理函数。当某个信号发生的时候,就默认执行这个函数就可以了。这就相当于咱们运维一个系统应急手册,当遇到什么情况,做什么事情,都事先准备好,出了事情照着做就可以了。
## 总结时刻
这一节,我们整体讲解了一下进程间通信的各种模式。你现在还能记住多少?
- 类似瀑布开发模式的管道
- 类似邮件模式的消息队列
- 类似会议室联合开发的共享内存加信号量
- 类似应急预案的信号
当你自己使用的时候,可以根据不同的通信需要,选择不同的模式。
- 管道,请你记住这是命令行中常用的模式,面试问到的话,不要忘了。
- 消息队列其实很少使用,因为有太多的用户级别的消息队列,功能更强大。
- 共享内存加信号量是常用的模式。这个需要牢记常见到一些知名的以C语言开发的开源软件都会用到它。
- 信号更加常用,机制也比较复杂。我们后面会有单独的一节来解析。
## 课堂练习
这节课的程序请你务必自己编译通过搞清楚参数解析是怎么做的这个以后你自己写程序的时候很有用另外消息队列模型的API调用流程也要搞清楚要知道他们都属于System V系列后面我们学共享内存和信号量能看到完全类似的API调用流程。
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,271 @@
<audio id="audio" title="37 | 信号项目组A完成了如何及时通知项目组B" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/15/fe/15bee5da75b7dc425621b1b30bd7c2fe.mp3"></audio>
上一节最后,我们讲了信号的机制。在某些紧急情况下,我们需要给进程发送一个信号,紧急处理一些事情。
这种方式有点儿像咱们运维一个线上系统,为了应对一些突发事件,往往需要制定应急预案。就像下面的列表中一样。一旦发生了突发事件,马上能够找到负责人,根据处理步骤进行紧急响应,并且在限定的事件内搞定。
<img src="https://static001.geekbang.org/resource/image/49/0c/498199918340c55f59c91129ceb59f0c.png" alt="">
我们现在就按照应急预案的设计思路来看一看Linux信号系统的机制。
首先,第一件要做的事情就是,整个团队要想一下,线上到底能够产生哪些异常情况,越全越好。于是,我们就有了上面这个很长很长的列表。
在Linux操作系统中为了响应各种各样的事件也是定义了非常多的信号。我们可以通过kill -l命令查看所有的信号。
```
# 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
```
这些信号都是什么作用呢我们可以通过man 7 signal命令查看里面会有一个列表。
```
Signal Value Action Comment
──────────────────────────────────────────────────────────────────────
SIGHUP 1 Term Hangup detected on controlling terminal
or death of controlling process
SIGINT 2 Term Interrupt from keyboard
SIGQUIT 3 Core Quit from keyboard
SIGILL 4 Core Illegal Instruction
SIGABRT 6 Core Abort signal from abort(3)
SIGFPE 8 Core Floating point exception
SIGKILL 9 Term Kill signal
SIGSEGV 11 Core Invalid memory reference
SIGPIPE 13 Term Broken pipe: write to pipe with no
readers
SIGALRM 14 Term Timer signal from alarm(2)
SIGTERM 15 Term Termination signal
SIGUSR1 30,10,16 Term User-defined signal 1
SIGUSR2 31,12,17 Term User-defined signal 2
……
```
就像应急预案里面给出的一样每个信号都有一个唯一的ID还有遇到这个信号的时候的默认操作。
一旦有信号产生,我们就有下面这几种,用户进程对信号的处理方式。
1.**执行默认操作**。Linux对每种信号都规定了默认操作例如上面列表中的Term就是终止进程的意思。Core的意思是Core Dump也即终止进程后通过Core Dump将当前进程的运行状态保存在文件里面方便程序员事后进行分析问题在哪里。
2.**捕捉信号**。我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。
3.**忽略信号**。当我们不希望处理某些信号的时候就可以忽略该信号不做任何处理。有两个信号是应用进程无法捕捉和忽略的即SIGKILL和SEGSTOP它们用于在任何时候中断或结束某一进程。
接下来,我们来看一下信号处理最常见的流程。这个过程主要是分成两步,第一步是注册信号处理函数。第二步是发送信号。这一节我们主要看第一步。
如果我们不想让某个信号执行默认操作,一种方法就是对特定的信号注册相应的信号处理函数,设置信号处理方式的是**signal函数**。
```
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
```
这其实就是定义一个方法,并且将这个方法和某个信号关联起来。当这个进程遇到这个信号的时候,就执行这个方法。
如果我们在Linux下面执行man signal的话会发现Linux不建议我们直接用这个方法而是改用sigaction。定义如下
```
int sigaction(int signum, const struct sigaction *act,
struct sigaction *oldact);
```
这两者的区别在哪里呢其实它还是将信号和一个动作进行关联只不过这个动作由一个结构struct sigaction表示了。
```
struct sigaction {
__sighandler_t sa_handler;
unsigned long sa_flags;
__sigrestore_t sa_restorer;
sigset_t sa_mask; /* mask last for extensibility */
};
```
和signal类似的是这里面还是有__sighandler_t。但是其他成员变量可以让你更加细致地控制信号处理的行为。而signal函数没有给你机会设置这些。这里需要注意的是signal不是系统调用而是glibc封装的一个函数。这样就像man signal里面写的一样不同的实现方式设置的参数会不同会导致行为的不同。
例如我们在glibc里面会看到了这样一个实现
```
# define signal __sysv_signal
__sighandler_t
__sysv_signal (int sig, __sighandler_t handler)
{
struct sigaction act, oact;
......
act.sa_handler = handler;
__sigemptyset (&amp;act.sa_mask);
act.sa_flags = SA_ONESHOT | SA_NOMASK | SA_INTERRUPT;
act.sa_flags &amp;= ~SA_RESTART;
if (__sigaction (sig, &amp;act, &amp;oact) &lt; 0)
return SIG_ERR;
return oact.sa_handler;
}
weak_alias (__sysv_signal, sysv_signal)
```
在这里面sa_flags进行了默认的设置。SA_ONESHOT是什么意思呢意思就是这里设置的信号处理函数仅仅起作用一次。用完了一次后就设置回默认行为。这其实并不是我们想看到的。毕竟我们一旦安装了一个信号处理函数肯定希望它一直起作用直到我显式地关闭它。
另外一个设置就是**SA_NOMASK**。我们通过__sigemptyset将sa_mask设置为空。这样的设置表示在这个信号处理函数执行过程中如果再有其他信号哪怕相同的信号到来的时候这个信号处理函数会被中断。如果一个信号处理函数真的被其他信号中断其实问题也不大因为当处理完了其他的信号处理函数后还会回来接着处理这个信号处理函数的但是对于相同的信号就有点尴尬了这就需要这个信号处理函数写得比较有技巧了。
例如,对于这个信号的处理过程中,要操作某个数据结构,因为是相同的信号,很可能操作的是同一个实例,这样的话,同步、死锁这些都要想好。其实一般的思路应该是,当某一个信号的信号处理函数运行的时候,我们暂时屏蔽这个信号。后面我们还会仔细分析屏蔽这个动作,屏蔽并不意味着信号一定丢失,而是暂存,这样能够做到信号处理函数对于相同的信号,处理完一个再处理下一个,这样信号处理函数的逻辑要简单得多。
还有一个设置就是设置了**SA_INTERRUPT清除了SA_RESTART**。这是什么意思呢我们知道信号的到来时间是不可预期的有可能程序正在调用某个漫长的系统调用的时候你可以在一台Linux机器上运行man 7 signal命令在这里找Interruption of system calls and library functions by signal handlers的部分里面说得非常详细这个时候一个信号来了会中断这个系统调用去执行信号处理函数那执行完了以后呢系统调用怎么办呢
这时候有两种处理方法一种就是SA_INTERRUPT也即系统调用被中断了就不再重试这个系统调用了而是直接返回一个-EINTR常量告诉调用方这个系统调用被信号中断了但是怎么处理你看着办。如果是这样的话调用方可以根据自己的逻辑重新调用或者直接返回这会使得我们的代码非常复杂在所有系统调用的返回值判断里面都要特殊判断一下这个值。
另外一种处理方法是SA_RESTART。这个时候系统调用会被自动重新启动不需要调用方自己写代码。当然也可能存在问题例如从终端读入一个字符这个时候用户在终端输入一个`'a'`字符,在处理`'a'`字符的时候被信号中断了,等信号处理完毕,再次读入一个字符的时候,如果用户不再输入,就停在那里了,需要用户再次输入同一个字符。
因此建议你使用sigaction函数根据自己的需要定制参数。
接下来我们来看sigaction具体做了些什么。
还记得在学习系统调用那一节的时候我们知道glibc里面有个文件syscalls.list。这里面定义了库函数调用哪些系统调用在这里我们找到了sigaction。
```
sigaction - sigaction i:ipp __sigaction sigaction
```
接下来在glibc中__sigaction会调用__libc_sigaction并最终调用的系统调用是rt_sigaction。
```
int
__sigaction (int sig, const struct sigaction *act, struct sigaction *oact)
{
......
return __libc_sigaction (sig, act, oact);
}
int
__libc_sigaction (int sig, const struct sigaction *act, struct sigaction *oact)
{
int result;
struct kernel_sigaction kact, koact;
if (act)
{
kact.k_sa_handler = act-&gt;sa_handler;
memcpy (&amp;kact.sa_mask, &amp;act-&gt;sa_mask, sizeof (sigset_t));
kact.sa_flags = act-&gt;sa_flags | SA_RESTORER;
kact.sa_restorer = &amp;restore_rt;
}
result = INLINE_SYSCALL (rt_sigaction, 4,
sig, act ? &amp;kact : NULL,
oact ? &amp;koact : NULL, _NSIG / 8);
if (oact &amp;&amp; result &gt;= 0)
{
oact-&gt;sa_handler = koact.k_sa_handler;
memcpy (&amp;oact-&gt;sa_mask, &amp;koact.sa_mask, sizeof (sigset_t));
oact-&gt;sa_flags = koact.sa_flags;
oact-&gt;sa_restorer = koact.sa_restorer;
}
return result;
}
```
这也是很多人看信号处理的内核实现的时候比较困惑的地方。例如内核代码注释里面会说系统调用signal是为了兼容过去系统调用sigaction也是为了兼容过去连参数都变成了struct compat_old_sigaction所以说我们的库函数虽然调用的是sigaction到了系统调用层调用的可不是系统调用sigaction而是系统调用rt_sigaction。
```
SYSCALL_DEFINE4(rt_sigaction, int, sig,
const struct sigaction __user *, act,
struct sigaction __user *, oact,
size_t, sigsetsize)
{
struct k_sigaction new_sa, old_sa;
int ret = -EINVAL;
......
if (act) {
if (copy_from_user(&amp;new_sa.sa, act, sizeof(new_sa.sa)))
return -EFAULT;
}
ret = do_sigaction(sig, act ? &amp;new_sa : NULL, oact ? &amp;old_sa : NULL);
if (!ret &amp;&amp; oact) {
if (copy_to_user(oact, &amp;old_sa.sa, sizeof(old_sa.sa)))
return -EFAULT;
}
out:
return ret;
}
```
在rt_sigaction里面我们将用户态的struct sigaction结构拷贝为内核态的k_sigaction然后调用do_sigaction。do_sigaction也很简单还记得进程内核的数据结构里struct task_struct里面有一个成员sighand里面有一个action。这是一个数组下标是信号内容就是信号处理函数do_sigaction就是设置sighand里的信号处理函数。
```
int do_sigaction(int sig, struct k_sigaction *act, struct k_sigaction *oact)
{
struct task_struct *p = current, *t;
struct k_sigaction *k;
sigset_t mask;
......
k = &amp;p-&gt;sighand-&gt;action[sig-1];
spin_lock_irq(&amp;p-&gt;sighand-&gt;siglock);
if (oact)
*oact = *k;
if (act) {
sigdelsetmask(&amp;act-&gt;sa.sa_mask,
sigmask(SIGKILL) | sigmask(SIGSTOP));
*k = *act;
......
}
spin_unlock_irq(&amp;p-&gt;sighand-&gt;siglock);
return 0;
}
```
至此,信号处理函数的注册已经完成了。
## 总结时刻
这一节讲了如何通过API注册一个信号处理函数整个过程如下图所示。
- 在用户程序里面有两个函数可以调用一个是signal一个是sigaction推荐使用sigaction。
- 用户程序调用的是Glibc里面的函数signal调用的是__sysv_signal里面默认设置了一些参数使得signal的功能受到了限制sigaction调用的是__sigaction参数用户可以任意设定。
- 无论是__sysv_signal还是__sigaction调用的都是统一的一个系统调用rt_sigaction。
- 在内核中rt_sigaction调用的是do_sigaction设置信号处理函数。在每一个进程的task_struct里面都有一个sighand指向struct sighand_struct里面是一个数组下标是信号里面的内容是信号处理函数。
<img src="https://static001.geekbang.org/resource/image/7c/28/7cb86c73b9e73893e6b0e0433d476928.png" alt="">
## 课堂练习
你可以试着写一个程序调用sigaction为某个信号设置一个信号处理函数在信号处理函数中如果收到信号则打印一些字符串然后用命令kill发送信号看是否字符串被正常输出。
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,480 @@
<audio id="audio" title="38 | 信号项目组A完成了如何及时通知项目组B" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b1/e5/b1615cc6dbdbe57497d8661780de59e5.mp3"></audio>
信号处理最常见的流程主要是两步,第一步是注册信号处理函数,第二步是发送信号和处理信号。上一节,我们讲了注册信号处理函数,那一般什么情况下会产生信号呢?我们这一节就来看一看。
## 信号的发送
有时候我们在终端输入某些组合键的时候会给进程发送信号例如Ctrl+C产生SIGINT信号Ctrl+Z产生SIGTSTP信号。
有的时候硬件异常也会产生信号。比如执行了除以0的指令CPU就会产生异常然后把SIGFPE信号发送给进程。再如进程访问了非法内存内存管理模块就会产生异常然后把信号SIGSEGV发送给进程。
这里同样是硬件产生的,对于中断和信号还是要加以区别。咱们前面讲过,中断要注册中断处理函数,但是中断处理函数是在内核驱动里面的,信号也要注册信号处理函数,信号处理函数是在用户态进程里面的。
对于硬件触发的无论是中断还是信号肯定是先到内核的然后内核对于中断和信号处理方式不同。一个是完全在内核里面处理完毕一个是将信号放在对应的进程task_struct里信号相关的数据结构里面然后等待进程在用户态去处理。当然有些严重的信号内核会把进程干掉。但是这也能看出来中断和信号的严重程度不一样信号影响的往往是某一个进程处理慢了甚至错了也不过这个进程被干掉而中断影响的是整个系统。一旦中断处理中有了bug可能整个Linux都挂了。
有时候内核在某些情况下也会给进程发送信号。例如向读端已关闭的管道写数据时产生SIGPIPE信号当子进程退出时我们要给父进程发送SIG_CHLD信号等。
最直接的发送信号的方法就是通过命令kill来发送信号了。例如我们都知道的kill -9 pid可以发送信号给一个进程杀死它。
另外我们还可以通过kill或者sigqueue系统调用发送信号给某个进程也可以通过tkill或者tgkill发送信号给某个线程。虽然方式多种多样但是最终都是调用了do_send_sig_info函数将信号放在相应的task_struct的信号数据结构中。
- kill-&gt;kill_something_info-&gt;kill_pid_info-&gt;group_send_sig_info-&gt;do_send_sig_info
- tkill-&gt;do_tkill-&gt;do_send_specific-&gt;do_send_sig_info
- tgkill-&gt;do_tkill-&gt;do_send_specific-&gt;do_send_sig_info
- rt_sigqueueinfo-&gt;do_rt_sigqueueinfo-&gt;kill_proc_info-&gt;kill_pid_info-&gt;group_send_sig_info-&gt;do_send_sig_info
do_send_sig_info会调用send_signal进而调用__send_signal。
```
SYSCALL_DEFINE2(kill, pid_t, pid, int, sig)
{
struct siginfo info;
info.si_signo = sig;
info.si_errno = 0;
info.si_code = SI_USER;
info.si_pid = task_tgid_vnr(current);
info.si_uid = from_kuid_munged(current_user_ns(), current_uid());
return kill_something_info(sig, &amp;info, pid);
}
static int __send_signal(int sig, struct siginfo *info, struct task_struct *t,
int group, int from_ancestor_ns)
{
struct sigpending *pending;
struct sigqueue *q;
int override_rlimit;
int ret = 0, result;
......
pending = group ? &amp;t-&gt;signal-&gt;shared_pending : &amp;t-&gt;pending;
......
if (legacy_queue(pending, sig))
goto ret;
if (sig &lt; SIGRTMIN)
override_rlimit = (is_si_special(info) || info-&gt;si_code &gt;= 0);
else
override_rlimit = 0;
q = __sigqueue_alloc(sig, t, GFP_ATOMIC | __GFP_NOTRACK_FALSE_POSITIVE,
override_rlimit);
if (q) {
list_add_tail(&amp;q-&gt;list, &amp;pending-&gt;list);
switch ((unsigned long) info) {
case (unsigned long) SEND_SIG_NOINFO:
q-&gt;info.si_signo = sig;
q-&gt;info.si_errno = 0;
q-&gt;info.si_code = SI_USER;
q-&gt;info.si_pid = task_tgid_nr_ns(current,
task_active_pid_ns(t));
q-&gt;info.si_uid = from_kuid_munged(current_user_ns(), current_uid());
break;
case (unsigned long) SEND_SIG_PRIV:
q-&gt;info.si_signo = sig;
q-&gt;info.si_errno = 0;
q-&gt;info.si_code = SI_KERNEL;
q-&gt;info.si_pid = 0;
q-&gt;info.si_uid = 0;
break;
default:
copy_siginfo(&amp;q-&gt;info, info);
if (from_ancestor_ns)
q-&gt;info.si_pid = 0;
break;
}
userns_fixup_signal_uid(&amp;q-&gt;info, t);
}
......
out_set:
signalfd_notify(t, sig);
sigaddset(&amp;pending-&gt;signal, sig);
complete_signal(sig, t, group);
ret:
return ret;
}
```
在这里我们看到在学习进程数据结构中task_struct里面的sigpending。在上面的代码里面我们先是要决定应该用哪个sigpending。这就要看我们发送的信号是给进程的还是线程的。如果是kill发送的也就是发送给整个进程的就应该发送给t-&gt;signal-&gt;shared_pending。这里面是整个进程所有线程共享的信号如果是tkill发送的也就是发给某个线程的就应该发给t-&gt;pending。这里面是这个线程的task_struct独享的。
struct sigpending里面有两个成员一个是一个集合sigset_t表示都收到了哪些信号还有一个链表也表示收到了哪些信号。它的结构如下
```
struct sigpending {
struct list_head list;
sigset_t signal;
};
```
如果都表示收到了信号这两者有什么区别呢我们接着往下看__send_signal里面的代码。接下来我们要调用legacy_queue。如果满足条件那就直接退出。那legacy_queue里面判断的是什么条件呢我们来看它的代码。
```
static inline int legacy_queue(struct sigpending *signals, int sig)
{
return (sig &lt; SIGRTMIN) &amp;&amp; sigismember(&amp;signals-&gt;signal, sig);
}
#define SIGRTMIN 32
#define SIGRTMAX _NSIG
#define _NSIG 64
```
当信号小于SIGRTMIN也即32的时候如果我们发现这个信号已经在集合里面了就直接退出了。这样会造成什么现象呢就是信号的丢失。例如我们发送给进程100个SIGUSR1对应的信号为10那最终能够被我们的信号处理函数处理的信号有多少呢这就不好说了比如总共5个SIGUSR1分别是A、B、C、D、E。
如果这五个信号来得太密。A来了但是信号处理函数还没来得及处理B、C、D、E就都来了。根据上面的逻辑因为A已经将SIGUSR1放在sigset_t集合中了因而后面四个都要丢失。 如果是另一种情况A来了已经被信号处理函数处理了内核在调用信号处理函数之前我们会将集合中的标志位清除这个时候B再来B还是会进入集合还是会被处理也就不会丢。
这样信号能够处理多少和信号处理函数什么时候被调用信号多大频率被发送都有关系而且从后面的分析我们可以知道信号处理函数的调用时间也是不确定的。看小于32的信号如此不靠谱我们就称它为**不可靠信号**。
如果大于32的信号是什么情况呢我们接着看。接下来__sigqueue_alloc会分配一个struct sigqueue对象然后通过list_add_tail挂在struct sigpending里面的链表上。这样就靠谱多了是不是如果发送过来100个信号变成链表上的100项都不会丢哪怕相同的信号发送多遍也处理多遍。因此大于32的信号我们称为**可靠信号**。当然队列的长度也是有限制的如果我们执行ulimit命令可以看到这个限制pending signals (-i) 15408。
当信号挂到了task_struct结构之后最后我们需要调用complete_signal。这里面的逻辑也很简单就是说既然这个进程有了一个新的信号赶紧找一个线程处理一下吧。
```
static void complete_signal(int sig, struct task_struct *p, int group)
{
struct signal_struct *signal = p-&gt;signal;
struct task_struct *t;
/*
* Now find a thread we can wake up to take the signal off the queue.
*
* If the main thread wants the signal, it gets first crack.
* Probably the least surprising to the average bear.
*/
if (wants_signal(sig, p))
t = p;
else if (!group || thread_group_empty(p))
/*
* There is just one thread and it does not need to be woken.
* It will dequeue unblocked signals before it runs again.
*/
return;
else {
/*
* Otherwise try to find a suitable thread.
*/
t = signal-&gt;curr_target;
while (!wants_signal(sig, t)) {
t = next_thread(t);
if (t == signal-&gt;curr_target)
return;
}
signal-&gt;curr_target = t;
}
......
/*
* The signal is already in the shared-pending queue.
* Tell the chosen thread to wake up and dequeue it.
*/
signal_wake_up(t, sig == SIGKILL);
return;
}
```
在找到了一个进程或者线程的task_struct之后我们要调用signal_wake_up来企图唤醒它signal_wake_up会调用signal_wake_up_state。
```
void signal_wake_up_state(struct task_struct *t, unsigned int state)
{
set_tsk_thread_flag(t, TIF_SIGPENDING);
if (!wake_up_state(t, state | TASK_INTERRUPTIBLE))
kick_process(t);
}
```
signal_wake_up_state里面主要做了两件事情。第一就是给这个线程设置TIF_SIGPENDING这就说明其实信号的处理和进程的调度是采取这样一种类似的机制。还记得咱们调度的时候是怎么操作的吗
当发现一个进程应该被调度的时候我们并不直接把它赶下来而是设置一个标识位TIF_NEED_RESCHED表示等待调度然后等待系统调用结束或者中断处理结束从内核态返回用户态的时候调用schedule函数进行调度。信号也是类似的当信号来的时候我们并不直接处理这个信号而是设置一个标识位TIF_SIGPENDING来表示已经有信号等待处理。同样等待系统调用结束或者中断处理结束从内核态返回用户态的时候再进行信号的处理。
signal_wake_up_state的第二件事情就是试图唤醒这个进程或者线程。wake_up_state会调用try_to_wake_up方法。这个函数我们讲进程的时候讲过就是将这个进程或者线程设置为TASK_RUNNING然后放在运行队列中这个时候当随着时钟不断的滴答迟早会被调用。如果wake_up_state返回0说明进程或者线程已经是TASK_RUNNING状态了如果它在另外一个CPU上运行则调用kick_process发送一个处理器间中断强制那个进程或者线程重新调度重新调度完毕后会返回用户态运行。这是一个时机会检查TIF_SIGPENDING标识位。
## 信号的处理
好了,信号已经发送到位了,什么时候真正处理它呢?
就是在从系统调用或者中断返回的时候咱们讲调度的时候讲过无论是从系统调用返回还是从中断返回都会调用exit_to_usermode_loop只不过我们上次主要关注了_TIF_NEED_RESCHED这个标识位这次我们重点关注**_TIF_SIGPENDING标识位**。
```
static void exit_to_usermode_loop(struct pt_regs *regs, u32 cached_flags)
{
while (true) {
......
if (cached_flags &amp; _TIF_NEED_RESCHED)
schedule();
......
/* deal with pending signal delivery */
if (cached_flags &amp; _TIF_SIGPENDING)
do_signal(regs);
......
if (!(cached_flags &amp; EXIT_TO_USERMODE_LOOP_FLAGS))
break;
}
}
```
如果在前一个环节中已经设置了_TIF_SIGPENDING我们就调用do_signal进行处理。
```
void do_signal(struct pt_regs *regs)
{
struct ksignal ksig;
if (get_signal(&amp;ksig)) {
/* Whee! Actually deliver the signal. */
handle_signal(&amp;ksig, regs);
return;
}
/* Did we come from a system call? */
if (syscall_get_nr(current, regs) &gt;= 0) {
/* Restart the system call - no handlers present */
switch (syscall_get_error(current, regs)) {
case -ERESTARTNOHAND:
case -ERESTARTSYS:
case -ERESTARTNOINTR:
regs-&gt;ax = regs-&gt;orig_ax;
regs-&gt;ip -= 2;
break;
case -ERESTART_RESTARTBLOCK:
regs-&gt;ax = get_nr_restart_syscall(regs);
regs-&gt;ip -= 2;
break;
}
}
restore_saved_sigmask();
}
```
do_signal会调用handle_signal。按说信号处理就是调用用户提供的信号处理函数但是这事儿没有看起来这么简单因为信号处理函数是在用户态的。
咱们又要来回忆系统调用的过程了。这个进程当时在用户态执行到某一行Line A调用了一个系统调用在进入内核的那一刻在内核pt_regs里面保存了用户态执行到了Line A。现在我们从系统调用返回用户态了按说应该从pt_regs拿出Line A然后接着Line A执行下去但是为了响应信号我们不能回到用户态的时候返回Line A了而是应该返回信号处理函数的起始地址。
```
static void
handle_signal(struct ksignal *ksig, struct pt_regs *regs)
{
bool stepping, failed;
......
/* Are we from a system call? */
if (syscall_get_nr(current, regs) &gt;= 0) {
/* If so, check system call restarting.. */
switch (syscall_get_error(current, regs)) {
case -ERESTART_RESTARTBLOCK:
case -ERESTARTNOHAND:
regs-&gt;ax = -EINTR;
break;
case -ERESTARTSYS:
if (!(ksig-&gt;ka.sa.sa_flags &amp; SA_RESTART)) {
regs-&gt;ax = -EINTR;
break;
}
/* fallthrough */
case -ERESTARTNOINTR:
regs-&gt;ax = regs-&gt;orig_ax;
regs-&gt;ip -= 2;
break;
}
}
......
failed = (setup_rt_frame(ksig, regs) &lt; 0);
......
signal_setup_done(failed, ksig, stepping);
}
```
这个时候我们就需要干预和自己来定制pt_regs了。这个时候我们要看是否从系统调用中返回。如果是从系统调用返回的话还要区分我们是从系统调用中正常返回还是在一个非运行状态的系统调用中因为会被信号中断而返回。
我们这里解析一个最复杂的场景。还记得咱们解析进程调度的时候我们举的一个例子就是从一个tap网卡中读取数据。当时我们主要关注schedule那一行也即如果当发现没有数据的时候就调用schedule自己进入等待状态然后将CPU让给其他进程。具体的代码如下
```
static ssize_t tap_do_read(struct tap_queue *q,
struct iov_iter *to,
int noblock, struct sk_buff *skb)
{
......
while (1) {
if (!noblock)
prepare_to_wait(sk_sleep(&amp;q-&gt;sk), &amp;wait,
TASK_INTERRUPTIBLE);
/* Read frames from the queue */
skb = skb_array_consume(&amp;q-&gt;skb_array);
if (skb)
break;
if (noblock) {
ret = -EAGAIN;
break;
}
if (signal_pending(current)) {
ret = -ERESTARTSYS;
break;
}
/* Nothing to read, let's sleep */
schedule();
}
......
}
```
这里我们关注和信号相关的部分。这其实是一个信号中断系统调用的典型逻辑。
首先我们把当前进程或者线程的状态设置为TASK_INTERRUPTIBLE这样才能使这个系统调用可以被中断。
其次可以被中断的系统调用往往是比较慢的调用并且会因为数据不就绪而通过schedule让出CPU进入等待状态。在发送信号的时候我们除了设置这个进程和线程的_TIF_SIGPENDING标识位之外还试图唤醒这个进程或者线程也就是将它从等待状态中设置为TASK_RUNNING。
当这个进程或者线程再次运行的时候我们根据进程调度第一定律从schedule函数中返回然后再次进入while循环。由于这个进程或者线程是由信号唤醒的而不是因为数据来了而唤醒的因而是读不到数据的但是在signal_pending函数中我们检测到了_TIF_SIGPENDING标识位这说明系统调用没有真的做完于是返回一个错误ERESTARTSYS然后带着这个错误从系统调用返回。
然后我们到了exit_to_usermode_loop-&gt;do_signal-&gt;handle_signal。在这里面当发现出现错误ERESTARTSYS的时候我们就知道这是从一个没有调用完的系统调用返回的设置系统调用错误码EINTR。
接下来我们就开始折腾pt_regs了主要通过调用setup_rt_frame-&gt;__setup_rt_frame。
```
static int __setup_rt_frame(int sig, struct ksignal *ksig,
sigset_t *set, struct pt_regs *regs)
{
struct rt_sigframe __user *frame;
void __user *fp = NULL;
int err = 0;
frame = get_sigframe(&amp;ksig-&gt;ka, regs, sizeof(struct rt_sigframe), &amp;fp);
......
put_user_try {
......
/* Set up to return from userspace. If provided, use a stub
already in userspace. */
/* x86-64 should always use SA_RESTORER. */
if (ksig-&gt;ka.sa.sa_flags &amp; SA_RESTORER) {
put_user_ex(ksig-&gt;ka.sa.sa_restorer, &amp;frame-&gt;pretcode);
}
} put_user_catch(err);
err |= setup_sigcontext(&amp;frame-&gt;uc.uc_mcontext, fp, regs, set-&gt;sig[0]);
err |= __copy_to_user(&amp;frame-&gt;uc.uc_sigmask, set, sizeof(*set));
/* Set up registers for signal handler */
regs-&gt;di = sig;
/* In case the signal handler was declared without prototypes */
regs-&gt;ax = 0;
regs-&gt;si = (unsigned long)&amp;frame-&gt;info;
regs-&gt;dx = (unsigned long)&amp;frame-&gt;uc;
regs-&gt;ip = (unsigned long) ksig-&gt;ka.sa.sa_handler;
regs-&gt;sp = (unsigned long)frame;
regs-&gt;cs = __USER_CS;
......
return 0;
}
```
frame的类型是rt_sigframe。frame的意思是帧。我们只有在学习栈的时候提到过栈帧的概念。对的这个frame就是一个栈帧。
我们在get_sigframe中会得到pt_regs的sp变量也就是原来这个程序在用户态的栈顶指针然后get_sigframe中我们会将sp减去sizeof(struct rt_sigframe)也就是把这个栈帧塞到了栈里面然后我们又在__setup_rt_frame中把regs-&gt;sp设置成等于frame。这就相当于强行在程序原来的用户态的栈里面插入了一个栈帧并在最后将regs-&gt;ip设置为用户定义的信号处理函数sa_handler。这意味着本来返回用户态应该接着原来的代码执行的现在不了要执行sa_handler了。那执行完了以后呢按照函数栈的规则弹出上一个栈帧来也就是弹出了frame。
那如果我们假设sa_handler成功返回了怎么回到程序原来在用户态运行的地方呢玄机就在frame里面。要想恢复原来运行的地方首先原来的pt_regs不能丢这个没问题是在setup_sigcontext里面将原来的pt_regs保存在了frame中的uc_mcontext里面。
另外很重要的一点程序如何跳过去呢在__setup_rt_frame中还有一个不引起重视的操作那就是通过put_user_ex将sa_restorer放到了frame-&gt;pretcode里面而且还是按照函数栈的规则。函数栈里面包含了函数执行完跳回去的地址。当sa_handler执行完之后弹出的函数栈是frame也就应该跳到sa_restorer的地址。这是什么地址呢
咱们在sigaction介绍的时候就没有介绍它在Glibc的__libc_sigaction函数中也没有注意到它被赋值成了restore_rt。这其实就是sa_handler执行完毕之后马上要执行的函数。从名字我们就能感觉到它将恢复原来程序运行的地方。
在Glibc中我们可以找到它的定义它竟然调用了一个系统调用系统调用号为__NR_rt_sigreturn。
```
RESTORE (restore_rt, __NR_rt_sigreturn)
#define RESTORE(name, syscall) RESTORE2 (name, syscall)
# define RESTORE2(name, syscall) \
asm \
( \
&quot;.LSTART_&quot; #name &quot;:\n&quot; \
&quot; .type __&quot; #name &quot;,@function\n&quot; \
&quot;__&quot; #name &quot;:\n&quot; \
&quot; movq $&quot; #syscall &quot;, %rax\n&quot; \
&quot; syscall\n&quot; \
......
```
我们可以在内核里面找到__NR_rt_sigreturn对应的系统调用。
```
asmlinkage long sys_rt_sigreturn(void)
{
struct pt_regs *regs = current_pt_regs();
struct rt_sigframe __user *frame;
sigset_t set;
unsigned long uc_flags;
frame = (struct rt_sigframe __user *)(regs-&gt;sp - sizeof(long));
if (__copy_from_user(&amp;set, &amp;frame-&gt;uc.uc_sigmask, sizeof(set)))
goto badframe;
if (__get_user(uc_flags, &amp;frame-&gt;uc.uc_flags))
goto badframe;
set_current_blocked(&amp;set);
if (restore_sigcontext(regs, &amp;frame-&gt;uc.uc_mcontext, uc_flags))
goto badframe;
......
return regs-&gt;ax;
......
}
```
在这里面我们把上次填充的那个rt_sigframe拿出来然后restore_sigcontext将pt_regs恢复成为原来用户态的样子。从这个系统调用返回的时候应用还误以为从上次的系统调用返回的呢。
至此,整个信号处理过程才全部结束。
## 总结时刻
信号的发送与处理是一个复杂的过程,这里来总结一下。
1. 假设我们有一个进程Amain函数里面调用系统调用进入内核。
1. 按照系统调用的原理会将用户态栈的信息保存在pt_regs里面也即记住原来用户态是运行到了line A的地方。
1. 在内核中执行系统调用读取数据。
1. 当发现没有什么数据可读取的时候只好进入睡眠状态并且调用schedule让出CPU这是进程调度第一定律。
1. 将进程状态设置为TASK_INTERRUPTIBLE可中断的睡眠状态也即如果有信号来的话是可以唤醒它的。
1. 其他的进程或者shell发送一个信号有四个函数可以调用kill、tkill、tgkill、rt_sigqueueinfo。
1. 四个发送信号的函数在内核中最终都是调用do_send_sig_info。
1. do_send_sig_info调用send_signal给进程A发送一个信号其实就是找到进程A的task_struct或者加入信号集合为不可靠信号或者加入信号链表为可靠信号。
1. do_send_sig_info调用signal_wake_up唤醒进程A。
1. 进程A重新进入运行状态TASK_RUNNING根据进程调度第一定律一定会接着schedule运行。
1. 进程A被唤醒后检查是否有信号到来如果没有重新循环到一开始尝试再次读取数据如果还是没有数据再次进入TASK_INTERRUPTIBLE即可中断的睡眠状态。
1. 当发现有信号到来的时候,就返回当前正在执行的系统调用,并返回一个错误表示系统调用被中断了。
1. 系统调用返回的时候会调用exit_to_usermode_loop。这是一个处理信号的时机。
1. 调用do_signal开始处理信号。
1. 根据信号得到信号处理函数sa_handler然后修改pt_regs中的用户态栈的信息让pt_regs指向sa_handler。同时修改用户态的栈插入一个栈帧sa_restorer里面保存了原来的指向line A的pt_regs并且设置让sa_handler运行完毕后跳到sa_restorer运行。
1. 返回用户态由于pt_regs已经设置为sa_handler则返回用户态执行sa_handler。
1. sa_handler执行完毕后信号处理函数就执行完了接着根据第15步对于用户态栈帧的修改会跳到sa_restorer运行。
1. sa_restorer会调用系统调用rt_sigreturn再次进入内核。
1. 在内核中rt_sigreturn恢复原来的pt_regs重新指向line A。
1. 从rt_sigreturn返回用户态还是调用exit_to_usermode_loop。
1. 这次因为pt_regs已经指向line A了于是就到了进程A中接着系统调用之后运行当然这个系统调用返回的是它被中断了没有执行完的错误。
<img src="https://static001.geekbang.org/resource/image/3d/fb/3dcb3366b11a3594b00805896b7731fb.png" alt="">
## 课堂练习
在Linux内核里面很多地方都存在信号和信号处理所以signal_pending这个函数也随处可见这样我们就能判断是否有信号发生。请你在内核代码中找到signal_pending出现的一些地方看有什么规律我们后面的章节会经常遇到它。
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,482 @@
<audio id="audio" title="39 | 管道项目组A完成了如何交接给项目组B" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9f/ca/9f9efe1fb4d988f2099ce48c8cd023ca.mp3"></audio>
在这一章的第一节里,我们大致讲了管道的使用方式以及相应的命令行。这一节,我们就具体来看一下管道是如何实现的。
我们先来看,我们常用的**匿名管道**Anonymous Pipes也即将多个命令串起来的竖线背后的原理到底是什么。
上次我们说,它是基于管道的,那管道如何创建呢?管道的创建,需要通过下面这个系统调用。
```
int pipe(int fd[2])
```
在这里我们创建了一个管道pipe返回了两个文件描述符这表示管道的两端一个是管道的读取端描述符fd[0]另一个是管道的写入端描述符fd[1]。
<img src="https://static001.geekbang.org/resource/image/8f/a7/8fa3144bf3a34ddf789884a75fa2d4a7.png" alt="">
我们来看在内核里面是如何实现的。
```
SYSCALL_DEFINE1(pipe, int __user *, fildes)
{
return sys_pipe2(fildes, 0);
}
SYSCALL_DEFINE2(pipe2, int __user *, fildes, int, flags)
{
struct file *files[2];
int fd[2];
int error;
error = __do_pipe_flags(fd, files, flags);
if (!error) {
if (unlikely(copy_to_user(fildes, fd, sizeof(fd)))) {
......
error = -EFAULT;
} else {
fd_install(fd[0], files[0]);
fd_install(fd[1], files[1]);
}
}
return error;
}
```
在内核中主要的逻辑在pipe2系统调用中。这里面要创建一个数组files用来存放管道的两端的打开文件另一个数组fd存放管道的两端的文件描述符。如果调用__do_pipe_flags没有错误那就调用fd_install将两个fd和两个struct file关联起来。这一点和打开一个文件的过程很像了。
我们来看__do_pipe_flags。这里面调用了create_pipe_files然后生成了两个fd。从这里可以看出fd[0]是用于读的fd[1]是用于写的。
```
static int __do_pipe_flags(int *fd, struct file **files, int flags)
{
int error;
int fdw, fdr;
......
error = create_pipe_files(files, flags);
......
error = get_unused_fd_flags(flags);
......
fdr = error;
error = get_unused_fd_flags(flags);
......
fdw = error;
fd[0] = fdr;
fd[1] = fdw;
return 0;
......
}
```
创建一个管道大部分的逻辑其实都是在create_pipe_files函数里面实现的。这一章第一节的时候我们说过命名管道是创建在文件系统上的。从这里我们可以看出匿名管道也是创建在文件系统上的只不过是一种特殊的文件系统创建一个特殊的文件对应一个特殊的inode就是这里面的get_pipe_inode。
```
int create_pipe_files(struct file **res, int flags)
{
int err;
struct inode *inode = get_pipe_inode();
struct file *f;
struct path path;
......
path.dentry = d_alloc_pseudo(pipe_mnt-&gt;mnt_sb, &amp;empty_name);
......
path.mnt = mntget(pipe_mnt);
d_instantiate(path.dentry, inode);
f = alloc_file(&amp;path, FMODE_WRITE, &amp;pipefifo_fops);
......
f-&gt;f_flags = O_WRONLY | (flags &amp; (O_NONBLOCK | O_DIRECT));
f-&gt;private_data = inode-&gt;i_pipe;
res[0] = alloc_file(&amp;path, FMODE_READ, &amp;pipefifo_fops);
......
path_get(&amp;path);
res[0]-&gt;private_data = inode-&gt;i_pipe;
res[0]-&gt;f_flags = O_RDONLY | (flags &amp; O_NONBLOCK);
res[1] = f;
return 0;
......
}
```
从get_pipe_inode的实现我们可以看出匿名管道来自一个特殊的文件系统pipefs。这个文件系统被挂载后我们就得到了struct vfsmount *pipe_mnt。然后挂载的文件系统的superblock就变成了pipe_mnt-&gt;mnt_sb。如果你对文件系统的操作还不熟悉要返回去复习一下文件系统那一章啊。
```
static struct file_system_type pipe_fs_type = {
.name = &quot;pipefs&quot;,
.mount = pipefs_mount,
.kill_sb = kill_anon_super,
};
static int __init init_pipe_fs(void)
{
int err = register_filesystem(&amp;pipe_fs_type);
if (!err) {
pipe_mnt = kern_mount(&amp;pipe_fs_type);
}
......
}
static struct inode * get_pipe_inode(void)
{
struct inode *inode = new_inode_pseudo(pipe_mnt-&gt;mnt_sb);
struct pipe_inode_info *pipe;
......
inode-&gt;i_ino = get_next_ino();
pipe = alloc_pipe_info();
......
inode-&gt;i_pipe = pipe;
pipe-&gt;files = 2;
pipe-&gt;readers = pipe-&gt;writers = 1;
inode-&gt;i_fop = &amp;pipefifo_fops;
inode-&gt;i_state = I_DIRTY;
inode-&gt;i_mode = S_IFIFO | S_IRUSR | S_IWUSR;
inode-&gt;i_uid = current_fsuid();
inode-&gt;i_gid = current_fsgid();
inode-&gt;i_atime = inode-&gt;i_mtime = inode-&gt;i_ctime = current_time(inode);
return inode;
......
}
```
我们从new_inode_pseudo函数创建一个inode。这里面开始填写Inode的成员这里和文件系统的很像。这里值得注意的是struct pipe_inode_info这个结构里面有个成员是struct pipe_buffer *bufs。我们可以知道**所谓的匿名管道,其实就是内核里面的一串缓存**。
另外一个需要注意的是pipefifo_fops将来我们对于文件描述符的操作在内核里面都是对应这里面的操作。
```
const struct file_operations pipefifo_fops = {
.open = fifo_open,
.llseek = no_llseek,
.read_iter = pipe_read,
.write_iter = pipe_write,
.poll = pipe_poll,
.unlocked_ioctl = pipe_ioctl,
.release = pipe_release,
.fasync = pipe_fasync,
};
```
我们回到create_pipe_files函数创建完了inode还需创建一个dentry和他对应。dentry和inode对应好了我们就要开始创建struct file对象了。先创建用于写入的对应的操作为pipefifo_fops再创建读取的对应的操作也为pipefifo_fops。然后把private_data设置为pipe_inode_info。这样从struct file这个层级上就能直接操作底层的读写操作。
至此一个匿名管道就创建成功了。如果对于fd[1]写入调用的是pipe_write向pipe_buffer里面写入数据如果对于fd[0]的读入调用的是pipe_read也就是从pipe_buffer里面读取数据。
但是这个时候两个文件描述符都是在一个进程里面的并没有起到进程间通信的作用怎么样才能使得管道是跨两个进程的呢还记得创建进程调用的fork吗在这里面创建的子进程会复制父进程的struct files_struct在这里面fd的数组会复制一份但是fd指向的struct file对于同一个文件还是只有一份这样就做到了两个进程各有两个fd指向同一个struct file的模式两个进程就可以通过各自的fd写入和读取同一个管道文件实现跨进程通信了。
<img src="https://static001.geekbang.org/resource/image/9c/a3/9c0e38e31c7a51da12faf4a1aca10ba3.png" alt="">
由于管道只能一端写入另一端读出所以上面的这种模式会造成混乱因为父进程和子进程都可以写入也都可以读出通常的方法是父进程关闭读取的fd只保留写入的fd而子进程关闭写入的fd只保留读取的fd如果需要双向通行则应该创建两个管道。
一个典型的使用管道在父子进程之间的通信代码如下:
```
#include &lt;unistd.h&gt;
#include &lt;fcntl.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;stdio.h&gt;
#include &lt;errno.h&gt;
#include &lt;string.h&gt;
int main(int argc, char *argv[])
{
int fds[2];
if (pipe(fds) == -1)
perror(&quot;pipe error&quot;);
pid_t pid;
pid = fork();
if (pid == -1)
perror(&quot;fork error&quot;);
if (pid == 0){
close(fds[0]);
char msg[] = &quot;hello world&quot;;
write(fds[1], msg, strlen(msg) + 1);
close(fds[1]);
exit(0);
} else {
close(fds[1]);
char msg[128];
read(fds[0], msg, 128);
close(fds[0]);
printf(&quot;message : %s\n&quot;, msg);
return 0;
}
}
```
<img src="https://static001.geekbang.org/resource/image/71/b6/71eb7b4d026d04e4093daad7e24feab6.png" alt="">
到这里我们仅仅解析了使用管道进行父子进程之间的通信但是我们在shell里面的不是这样的。在shell里面运行A|B的时候A进程和B进程都是shell创建出来的子进程A和B之间不存在父子关系。
不过有了上面父子进程之间的管道这个基础实现A和B之间的管道就方便多了。
我们首先从shell创建子进程A然后在shell和A之间建立一个管道其中shell保留读取端A进程保留写入端然后shell再创建子进程B。这又是一次fork所以shell里面保留的读取端的fd也被复制到了子进程B里面。这个时候相当于shell和B都保留读取端只要shell主动关闭读取端就变成了一管道写入端在A进程读取端在B进程。
<img src="https://static001.geekbang.org/resource/image/81/fa/81be4d460aaa804e9176ec70d59fdefa.png" alt="">
接下来我们要做的事情就是将这个管道的两端和输入输出关联起来。这就要用到dup2系统调用了。
```
int dup2(int oldfd, int newfd);
```
这个系统调用将老的文件描述符赋值给新的文件描述符让newfd的值和oldfd一样。
我们还是回忆一下在files_struct里面有这样一个表下标是fd内容指向一个打开的文件struct file。
```
struct files_struct {
struct file __rcu * fd_array[NR_OPEN_DEFAULT];
}
```
在这个表里面前三项是定下来的其中第零项STDIN_FILENO表示标准输入第一项STDOUT_FILENO表示标准输出第三项STDERR_FILENO表示错误输出。
在A进程中写入端可以做这样的操作dup2(fd[1],STDOUT_FILENO)将STDOUT_FILENO也即第一项不再指向标准输出而是指向创建的管道文件那么以后往标准输出写入的任何东西都会写入管道文件。
在B进程中读取端可以做这样的操作dup2(fd[0],STDIN_FILENO)将STDIN_FILENO也即第零项不再指向标准输入而是指向创建的管道文件那么以后从标准输入读取的任何东西都来自于管道文件。
至此我们才将A|B的功能完成。
<img src="https://static001.geekbang.org/resource/image/c0/e2/c042b12de704995e4ba04173e0a304e2.png" alt="">
为了模拟A|B的情况我们可以将前面的那一段代码进一步修改成下面这样
```
#include &lt;unistd.h&gt;
#include &lt;fcntl.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;stdio.h&gt;
#include &lt;errno.h&gt;
#include &lt;string.h&gt;
int main(int argc, char *argv[])
{
int fds[2];
if (pipe(fds) == -1)
perror(&quot;pipe error&quot;);
pid_t pid;
pid = fork();
if (pid == -1)
perror(&quot;fork error&quot;);
if (pid == 0){
dup2(fds[1], STDOUT_FILENO);
close(fds[1]);
close(fds[0]);
execlp(&quot;ps&quot;, &quot;ps&quot;, &quot;-ef&quot;, NULL);
} else {
dup2(fds[0], STDIN_FILENO);
close(fds[0]);
close(fds[1]);
execlp(&quot;grep&quot;, &quot;grep&quot;, &quot;systemd&quot;, NULL);
}
return 0;
}
```
接下来我们来看命名管道。我们在讲命令的时候讲过命名管道需要事先通过命令mkfifo进行创建。如果是通过代码创建命名管道也有一个函数但是这不是一个系统调用而是Glibc提供的函数。它的定义如下
```
int
mkfifo (const char *path, mode_t mode)
{
dev_t dev = 0;
return __xmknod (_MKNOD_VER, path, mode | S_IFIFO, &amp;dev);
}
int
__xmknod (int vers, const char *path, mode_t mode, dev_t *dev)
{
unsigned long long int k_dev;
......
/* We must convert the value to dev_t type used by the kernel. */
k_dev = (*dev) &amp; ((1ULL &lt;&lt; 32) - 1);
......
return INLINE_SYSCALL (mknodat, 4, AT_FDCWD, path, mode,
(unsigned int) k_dev);
}
```
Glibc的mkfifo函数会调用mknodat系统调用还记得咱们学字符设备的时候创建一个字符设备的时候也是调用的mknod。这里命名管道也是一个设备因而我们也用mknod。
```
SYSCALL_DEFINE4(mknodat, int, dfd, const char __user *, filename, umode_t, mode, unsigned, dev)
{
struct dentry *dentry;
struct path path;
unsigned int lookup_flags = 0;
......
retry:
dentry = user_path_create(dfd, filename, &amp;path, lookup_flags);
......
switch (mode &amp; S_IFMT) {
......
case S_IFIFO: case S_IFSOCK:
error = vfs_mknod(path.dentry-&gt;d_inode,dentry,mode,0);
break;
}
......
}
```
对于mknod的解析我们在字符设备那一节已经解析过了先是通过user_path_create对于这个管道文件创建一个dentry然后因为是S_IFIFO所以调用vfs_mknod。由于这个管道文件是创建在一个普通文件系统上的假设是在ext4文件上于是vfs_mknod会调用ext4_dir_inode_operations的mknod也即会调用ext4_mknod。
```
const struct inode_operations ext4_dir_inode_operations = {
......
.mknod = ext4_mknod,
......
};
static int ext4_mknod(struct inode *dir, struct dentry *dentry,
umode_t mode, dev_t rdev)
{
handle_t *handle;
struct inode *inode;
......
inode = ext4_new_inode_start_handle(dir, mode, &amp;dentry-&gt;d_name, 0,
NULL, EXT4_HT_DIR, credits);
handle = ext4_journal_current_handle();
if (!IS_ERR(inode)) {
init_special_inode(inode, inode-&gt;i_mode, rdev);
inode-&gt;i_op = &amp;ext4_special_inode_operations;
err = ext4_add_nondir(handle, dentry, inode);
if (!err &amp;&amp; IS_DIRSYNC(dir))
ext4_handle_sync(handle);
}
if (handle)
ext4_journal_stop(handle);
......
}
#define ext4_new_inode_start_handle(dir, mode, qstr, goal, owner, \
type, nblocks) \
__ext4_new_inode(NULL, (dir), (mode), (qstr), (goal), (owner), \
0, (type), __LINE__, (nblocks))
void init_special_inode(struct inode *inode, umode_t mode, dev_t rdev)
{
inode-&gt;i_mode = mode;
if (S_ISCHR(mode)) {
inode-&gt;i_fop = &amp;def_chr_fops;
inode-&gt;i_rdev = rdev;
} else if (S_ISBLK(mode)) {
inode-&gt;i_fop = &amp;def_blk_fops;
inode-&gt;i_rdev = rdev;
} else if (S_ISFIFO(mode))
inode-&gt;i_fop = &amp;pipefifo_fops;
else if (S_ISSOCK(mode))
; /* leave it no_open_fops */
else
......
}
```
在ext4_mknod中ext4_new_inode_start_handle会调用__ext4_new_inode在ext4文件系统上真的创建一个文件但是会调用init_special_inode创建一个内存中特殊的inode这个函数我们在字符设备文件中也遇到过只不过当时inode的i_fop指向的是def_chr_fops这次换成管道文件了inode的i_fop变成指向pipefifo_fops这一点和匿名管道是一样的。
这样,管道文件就创建完毕了。
接下来要打开这个管道文件我们还是会调用文件系统的open函数。还是沿着文件系统的调用方式一路调用到pipefifo_fops的open函数也就是fifo_open。
```
static int fifo_open(struct inode *inode, struct file *filp)
{
struct pipe_inode_info *pipe;
bool is_pipe = inode-&gt;i_sb-&gt;s_magic == PIPEFS_MAGIC;
int ret;
filp-&gt;f_version = 0;
if (inode-&gt;i_pipe) {
pipe = inode-&gt;i_pipe;
pipe-&gt;files++;
} else {
pipe = alloc_pipe_info();
pipe-&gt;files = 1;
inode-&gt;i_pipe = pipe;
spin_unlock(&amp;inode-&gt;i_lock);
}
filp-&gt;private_data = pipe;
filp-&gt;f_mode &amp;= (FMODE_READ | FMODE_WRITE);
switch (filp-&gt;f_mode) {
case FMODE_READ:
pipe-&gt;r_counter++;
if (pipe-&gt;readers++ == 0)
wake_up_partner(pipe);
if (!is_pipe &amp;&amp; !pipe-&gt;writers) {
if ((filp-&gt;f_flags &amp; O_NONBLOCK)) {
filp-&gt;f_version = pipe-&gt;w_counter;
} else {
if (wait_for_partner(pipe, &amp;pipe-&gt;w_counter))
goto err_rd;
}
}
break;
case FMODE_WRITE:
pipe-&gt;w_counter++;
if (!pipe-&gt;writers++)
wake_up_partner(pipe);
if (!is_pipe &amp;&amp; !pipe-&gt;readers) {
if (wait_for_partner(pipe, &amp;pipe-&gt;r_counter))
goto err_wr;
}
break;
case FMODE_READ | FMODE_WRITE:
pipe-&gt;readers++;
pipe-&gt;writers++;
pipe-&gt;r_counter++;
pipe-&gt;w_counter++;
if (pipe-&gt;readers == 1 || pipe-&gt;writers == 1)
wake_up_partner(pipe);
break;
......
}
......
}
```
在fifo_open里面创建pipe_inode_info这一点和匿名管道也是一样的。这个结构里面有个成员是struct pipe_buffer *bufs。我们可以知道**所谓的命名管道,其实是也是内核里面的一串缓存。**
接下来对于命名管道的写入我们还是会调用pipefifo_fops的pipe_write函数向pipe_buffer里面写入数据。对于命名管道的读入我们还是会调用pipefifo_fops的pipe_read也就是从pipe_buffer里面读取数据。
## 总结时刻
无论是匿名管道还是命名管道在内核都是一个文件。只要是文件就要有一个inode。这里我们又用到了特殊inode、字符设备、块设备其实都是这种特殊的inode。
在这种特殊的inode里面file_operations指向管道特殊的pipefifo_fops这个inode对应内存里面的缓存。
当我们用文件的open函数打开这个管道设备文件的时候会调用pipefifo_fops里面的方法创建struct file结构他的inode指向特殊的inode也对应内存里面的缓存file_operations也指向管道特殊的pipefifo_fops。
写入一个pipe就是从struct file结构找到缓存写入读取一个pipe就是从struct file结构找到缓存读出。
<img src="https://static001.geekbang.org/resource/image/48/97/486e2bc73abbe91d7083bb1f4f678097.png" alt="">
## 课堂练习
上面创建匿名管道的程序你一定要运行一下然后试着通过strace查看自己写的程序的系统调用以及直接在命令行使用匿名管道的系统调用做一个比较。
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,297 @@
<audio id="audio" title="40 | IPC不同项目组之间抢资源如何协调" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/52/52/5292ca2ad2cdc2f2d54796e5c3d55b52.mp3"></audio>
我们前面讲了,如果项目组之间需要紧密合作,那就需要共享内存,这样就像把两个项目组放在一个会议室一起沟通,会非常高效。这一节,我们就来详细讲讲这个进程之间共享内存的机制。
有了这个机制,两个进程可以像访问自己内存中的变量一样,访问共享内存的变量。但是同时问题也来了,当两个进程共享内存了,就会存在同时读写的问题,就需要对于共享的内存进行保护,就需要信号量这样的同步协调机制。这些也都是我们这节需要探讨的问题。下面我们就一一来看。
共享内存和信号量也是System V系列的进程间通信机制所以很多地方和我们讲过的消息队列有点儿像。为了将共享内存和信号量结合起来使用我这里定义了一个share.h头文件里面放了一些共享内存和信号量在每个进程都需要的函数。
```
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;sys/ipc.h&gt;
#include &lt;sys/shm.h&gt;
#include &lt;sys/types.h&gt;
#include &lt;sys/sem.h&gt;
#include &lt;string.h&gt;
#define MAX_NUM 128
struct shm_data {
int data[MAX_NUM];
int datalength;
};
union semun {
int val;
struct semid_ds *buf;
unsigned short int *array;
struct seminfo *__buf;
};
int get_shmid(){
int shmid;
key_t key;
if((key = ftok(&quot;/root/sharememory/sharememorykey&quot;, 1024)) &lt; 0){
perror(&quot;ftok error&quot;);
return -1;
}
shmid = shmget(key, sizeof(struct shm_data), IPC_CREAT|0777);
return shmid;
}
int get_semaphoreid(){
int semid;
key_t key;
if((key = ftok(&quot;/root/sharememory/semaphorekey&quot;, 1024)) &lt; 0){
perror(&quot;ftok error&quot;);
return -1;
}
semid = semget(key, 1, IPC_CREAT|0777);
return semid;
}
int semaphore_init (int semid) {
union semun argument;
unsigned short values[1];
values[0] = 1;
argument.array = values;
return semctl (semid, 0, SETALL, argument);
}
int semaphore_p (int semid) {
struct sembuf operations[1];
operations[0].sem_num = 0;
operations[0].sem_op = -1;
operations[0].sem_flg = SEM_UNDO;
return semop (semid, operations, 1);
}
int semaphore_v (int semid) {
struct sembuf operations[1];
operations[0].sem_num = 0;
operations[0].sem_op = 1;
operations[0].sem_flg = SEM_UNDO;
return semop (semid, operations, 1);
}
```
## 共享内存
我们先来看里面对于共享内存的操作。
首先创建之前我们要有一个key来唯一标识这个共享内存。这个key可以根据文件系统上的一个文件的inode随机生成。
然后我们需要创建一个共享内存就像创建一个消息队列差不多都是使用xxxget来创建。其中创建共享内存使用的是下面这个函数
```
int shmget(key_t key, size_t size, int shmflag);
```
其中key就是前面生成的那个keyshmflag如果为IPC_CREAT就表示新创建还可以指定读写权限0777。
对于共享内存需要指定一个大小size这个一般要申请多大呢一个最佳实践是我们将多个进程需要共享的数据放在一个struct里面然后这里的size就应该是这个struct的大小。这样每一个进程得到这块内存后只要强制将类型转换为这个struct类型就能够访问里面的共享数据了。
在这里我们定义了一个struct shm_data结构。这里面有两个成员一个是一个整型的数组一个是数组中元素的个数。
生成了共享内存以后,接下来就是将这个共享内存映射到进程的虚拟地址空间中。我们使用下面这个函数来进行操作。
```
void *shmat(int shm_id, const void *addr, int shmflg);
```
这里面的shm_id就是上面创建的共享内存的idaddr就是指定映射在某个地方。如果不指定则内核会自动选择一个地址作为返回值返回。得到了返回地址以后我们需要将指针强制类型转换为struct shm_data结构就可以使用这个指针设置data和datalength了。
当共享内存使用完毕我们可以通过shmdt解除它到虚拟内存的映射。
```
int shmdt(const void *shmaddr)
```
## 信号量
看完了共享内存,接下来我们再来看信号量。信号量以集合的形式存在的。
首先创建之前我们同样需要有一个key来唯一标识这个信号量集合。这个key同样可以根据文件系统上的一个文件的inode随机生成。
然后我们需要创建一个信号量集合同样也是使用xxxget来创建其中创建信号量集合使用的是下面这个函数。
```
int semget(key_t key, int nsems, int semflg);
```
这里面的key就是前面生成的那个keyshmflag如果为IPC_CREAT就表示新创建还可以指定读写权限0777。
这里nsems表示这个信号量集合里面有几个信号量最简单的情况下我们设置为1。
信号量往往代表某种资源的数量如果用信号量做互斥那往往将信号量设置为1。这就是上面代码中semaphore_init函数的作用这里面调用semctl函数将这个信号量集合的中的第0个信号量也即唯一的这个信号量设置为1。
对于信号量往往要定义两种操作P操作和V操作。对应上面代码中semaphore_p函数和semaphore_v函数semaphore_p会调用semop函数将信号量的值减一表示申请占用一个资源当发现当前没有资源的时候进入等待。semaphore_v会调用semop函数将信号量的值加一表示释放一个资源释放之后就允许等待中的其他进程占用这个资源。
我们可以用这个信号量来保护共享内存中的struct shm_data使得同时只有一个进程可以操作这个结构。
你是否记得咱们讲线程同步机制的时候构建了一个老板分配活的场景。这里我们同样构建一个场景分为producer.c和consumer.c其中producer也即生产者负责往struct shm_data塞入数据而consumer.c负责处理struct shm_data中的数据。
下面我们来看producer.c的代码。
```
#include &quot;share.h&quot;
int main() {
void *shm = NULL;
struct shm_data *shared = NULL;
int shmid = get_shmid();
int semid = get_semaphoreid();
int i;
shm = shmat(shmid, (void*)0, 0);
if(shm == (void*)-1){
exit(0);
}
shared = (struct shm_data*)shm;
memset(shared, 0, sizeof(struct shm_data));
semaphore_init(semid);
while(1){
semaphore_p(semid);
if(shared-&gt;datalength &gt; 0){
semaphore_v(semid);
sleep(1);
} else {
printf(&quot;how many integers to caculate : &quot;);
scanf(&quot;%d&quot;,&amp;shared-&gt;datalength);
if(shared-&gt;datalength &gt; MAX_NUM){
perror(&quot;too many integers.&quot;);
shared-&gt;datalength = 0;
semaphore_v(semid);
exit(1);
}
for(i=0;i&lt;shared-&gt;datalength;i++){
printf(&quot;Input the %d integer : &quot;, i);
scanf(&quot;%d&quot;,&amp;shared-&gt;data[i]);
}
semaphore_v(semid);
}
}
}
```
在这里面get_shmid创建了共享内存get_semaphoreid创建了信号量集合然后shmat将共享内存映射到了虚拟地址空间的shm指针指向的位置然后通过强制类型转换shared的指针指向放在共享内存里面的struct shm_data结构然后初始化为0。semaphore_init将信号量进行了初始化。
接着producer进入了一个无限循环。在这个循环里面我们先通过semaphore_p申请访问共享内存的权利如果发现datalength大于零说明共享内存里面的数据没有被处理过于是semaphore_v释放权利先睡一会儿睡醒了再看。如果发现datalength等于0说明共享内存里面的数据被处理完了于是开始往里面放数据。让用户输入多少个数然后每个数是什么都放在struct shm_data结构中然后semaphore_v释放权利等待其他的进程将这些数拿去处理。
我们再来看consumer的代码。
```
#include &quot;share.h&quot;
int main() {
void *shm = NULL;
struct shm_data *shared = NULL;
int shmid = get_shmid();
int semid = get_semaphoreid();
int i;
shm = shmat(shmid, (void*)0, 0);
if(shm == (void*)-1){
exit(0);
}
shared = (struct shm_data*)shm;
while(1){
semaphore_p(semid);
if(shared-&gt;datalength &gt; 0){
int sum = 0;
for(i=0;i&lt;shared-&gt;datalength-1;i++){
printf(&quot;%d+&quot;,shared-&gt;data[i]);
sum += shared-&gt;data[i];
}
printf(&quot;%d&quot;,shared-&gt;data[shared-&gt;datalength-1]);
sum += shared-&gt;data[shared-&gt;datalength-1];
printf(&quot;=%d\n&quot;,sum);
memset(shared, 0, sizeof(struct shm_data));
semaphore_v(semid);
} else {
semaphore_v(semid);
printf(&quot;no tasks, waiting.\n&quot;);
sleep(1);
}
}
}
```
在这里面get_shmid获得producer创建的共享内存get_semaphoreid获得producer创建的信号量集合然后shmat将共享内存映射到了虚拟地址空间的shm指针指向的位置然后通过强制类型转换shared的指针指向放在共享内存里面的struct shm_data结构。
接着consumer进入了一个无限循环在这个循环里面我们先通过semaphore_p申请访问共享内存的权利如果发现datalength等于0就说明没什么活干需要等待。如果发现datalength大于0就说明有活干于是将datalength个整型数字从data数组中取出来求和。最后将struct shm_data清空为0表示任务处理完毕通过semaphore_v释放权利。
通过程序创建的共享内存和信号量集合我们可以通过命令ipcs查看。当然我们也可以通过ipcrm进行删除。
```
# ipcs
------ Message Queues --------
key msqid owner perms used-bytes messages
------ Shared Memory Segments --------
key shmid owner perms bytes nattch status
0x00016988 32768 root 777 516 0
------ Semaphore Arrays --------
key semid owner perms nsems
0x00016989 32768 root 777 1
```
下面我们来运行一下producer和consumer可以得到下面的结果
```
# ./producer
how many integers to caculate : 2
Input the 0 integer : 3
Input the 1 integer : 4
how many integers to caculate : 4
Input the 0 integer : 3
Input the 1 integer : 4
Input the 2 integer : 5
Input the 3 integer : 6
how many integers to caculate : 7
Input the 0 integer : 9
Input the 1 integer : 8
Input the 2 integer : 7
Input the 3 integer : 6
Input the 4 integer : 5
Input the 5 integer : 4
Input the 6 integer : 3
# ./consumer
3+4=7
3+4+5+6=18
9+8+7+6+5+4+3=42
```
## 总结时刻
这一节的内容差不多了,我们来总结一下。共享内存和信号量的配合机制,如下图所示:
- 无论是共享内存还是信号量创建与初始化都遵循同样流程通过ftok得到key通过xxxget创建对象并生成id
- 生产者和消费者都通过shmat将共享内存映射到各自的内存空间在不同的进程里面映射的位置不同
- 为了访问共享内存需要信号量进行保护信号量需要通过semctl初始化为某个值
- 接下来生产者和消费者要通过semop(-1)来竞争信号量如果生产者抢到信号量则写入然后通过semop(+1)释放信号量如果消费者抢到信号量则读出然后通过semop(+1)释放信号量;
- 共享内存使用完毕可以通过shmdt来解除映射。
<img src="https://static001.geekbang.org/resource/image/46/0b/469552bffe601d594c432d4fad97490b.png" alt="">
## 课堂练习
信号量大于1的情况下应该如何使用你可以试着构建一个场景。
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,576 @@
<audio id="audio" title="41 | IPC不同项目组之间抢资源如何协调" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5e/d6/5e5caec562b544ec17f47848e115cdd6.mp3"></audio>
了解了如何使用共享内存和信号量集合之后,今天我们来解析一下,内核里面都做了什么。
不知道你有没有注意到,咱们讲消息队列、共享内存、信号量的机制的时候,我们其实能够从中看到一些统一的规律:**它们在使用之前都要生成key然后通过key得到唯一的id并且都是通过xxxget函数。**
在内核里面这三种进程间通信机制是使用统一的机制管理起来的都叫ipcxxx。
为了维护这三种进程间通信进制,在内核里面,我们声明了一个有三项的数组。
我们通过这段代码,来具体看一看。
```
struct ipc_namespace {
......
struct ipc_ids ids[3];
......
}
#define IPC_SEM_IDS 0
#define IPC_MSG_IDS 1
#define IPC_SHM_IDS 2
#define sem_ids(ns) ((ns)-&gt;ids[IPC_SEM_IDS])
#define msg_ids(ns) ((ns)-&gt;ids[IPC_MSG_IDS])
#define shm_ids(ns) ((ns)-&gt;ids[IPC_SHM_IDS])
```
根据代码中的定义第0项用于信号量第1项用于消息队列第2项用于共享内存分别可以通过sem_ids、msg_ids、shm_ids来访问。
这段代码里面有ns全称叫namespace。可能不容易理解你现在可以将它认为是将一台Linux服务器逻辑的隔离为多台Linux服务器的机制它背后的原理是一个相当大的话题我们需要在容器那一章详细讲述。现在你就可以简单的认为没有namespace整个Linux在一个namespace下面那这些ids也是整个Linux只有一份。
接下来我们再来看struct ipc_ids里面保存了什么。
首先in_use表示当前有多少个ipc其次seq和next_id用于一起生成ipc唯一的id因为信号量共享内存消息队列它们三个的id也不能重复ipcs_idr是一棵基数树我们又碰到它了一旦涉及从一个整数查找一个对象它都是最好的选择。
```
struct ipc_ids {
int in_use;
unsigned short seq;
struct rw_semaphore rwsem;
struct idr ipcs_idr;
int next_id;
};
struct idr {
struct radix_tree_root idr_rt;
unsigned int idr_next;
};
```
也就是说对于sem_ids、msg_ids、shm_ids各有一棵基数树。那这棵树里面究竟存放了什么能够统一管理这三类ipc对象呢
通过下面这个函数ipc_obtain_object_idr我们可以看出端倪。这个函数根据id在基数树里面找出来的是struct kern_ipc_perm。
```
struct kern_ipc_perm *ipc_obtain_object_idr(struct ipc_ids *ids, int id)
{
struct kern_ipc_perm *out;
int lid = ipcid_to_idx(id);
out = idr_find(&amp;ids-&gt;ipcs_idr, lid);
return out;
}
```
如果我们看用于表示信号量、消息队列、共享内存的结构就会发现这三个结构的第一项都是struct kern_ipc_perm。
```
struct sem_array {
struct kern_ipc_perm sem_perm; /* permissions .. see ipc.h */
time_t sem_ctime; /* create/last semctl() time */
struct list_head pending_alter; /* pending operations */
/* that alter the array */
struct list_head pending_const; /* pending complex operations */
/* that do not alter semvals */
struct list_head list_id; /* undo requests on this array */
int sem_nsems; /* no. of semaphores in array */
int complex_count; /* pending complex operations */
unsigned int use_global_lock;/* &gt;0: global lock required */
struct sem sems[];
} __randomize_layout;
struct msg_queue {
struct kern_ipc_perm q_perm;
time_t q_stime; /* last msgsnd time */
time_t q_rtime; /* last msgrcv time */
time_t q_ctime; /* last change time */
unsigned long q_cbytes; /* current number of bytes on queue */
unsigned long q_qnum; /* number of messages in queue */
unsigned long q_qbytes; /* max number of bytes on queue */
pid_t q_lspid; /* pid of last msgsnd */
pid_t q_lrpid; /* last receive pid */
struct list_head q_messages;
struct list_head q_receivers;
struct list_head q_senders;
} __randomize_layout;
struct shmid_kernel /* private to the kernel */
{
struct kern_ipc_perm shm_perm;
struct file *shm_file;
unsigned long shm_nattch;
unsigned long shm_segsz;
time_t shm_atim;
time_t shm_dtim;
time_t shm_ctim;
pid_t shm_cprid;
pid_t shm_lprid;
struct user_struct *mlock_user;
/* The task created the shm object. NULL if the task is dead. */
struct task_struct *shm_creator;
struct list_head shm_clist; /* list by creator */
} __randomize_layout;
```
也就是说我们完全可以通过struct kern_ipc_perm的指针通过进行强制类型转换后得到整个结构。做这件事情的函数如下
```
static inline struct sem_array *sem_obtain_object(struct ipc_namespace *ns, int id)
{
struct kern_ipc_perm *ipcp = ipc_obtain_object_idr(&amp;sem_ids(ns), id);
return container_of(ipcp, struct sem_array, sem_perm);
}
static inline struct msg_queue *msq_obtain_object(struct ipc_namespace *ns, int id)
{
struct kern_ipc_perm *ipcp = ipc_obtain_object_idr(&amp;msg_ids(ns), id);
return container_of(ipcp, struct msg_queue, q_perm);
}
static inline struct shmid_kernel *shm_obtain_object(struct ipc_namespace *ns, int id)
{
struct kern_ipc_perm *ipcp = ipc_obtain_object_idr(&amp;shm_ids(ns), id);
return container_of(ipcp, struct shmid_kernel, shm_perm);
}
```
通过这种机制我们就可以将信号量、消息队列、共享内存抽象为ipc类型进行统一处理。你有没有觉得这有点儿面向对象编程中抽象类和实现类的意思没错如果你试图去了解C++中类的实现机制,其实也是这么干的。
<img src="https://static001.geekbang.org/resource/image/08/af/082b742753d862cfeae520fb02aa41af.png" alt="">
有了抽象类,接下来我们来看共享内存和信号量的具体实现。
## 如何创建共享内存?
首先,我们来看创建共享内存的的系统调用。
```
SYSCALL_DEFINE3(shmget, key_t, key, size_t, size, int, shmflg)
{
struct ipc_namespace *ns;
static const struct ipc_ops shm_ops = {
.getnew = newseg,
.associate = shm_security,
.more_checks = shm_more_checks,
};
struct ipc_params shm_params;
ns = current-&gt;nsproxy-&gt;ipc_ns;
shm_params.key = key;
shm_params.flg = shmflg;
shm_params.u.size = size;
return ipcget(ns, &amp;shm_ids(ns), &amp;shm_ops, &amp;shm_params);
}
```
这里面调用了抽象的ipcget、参数分别为共享内存对应的shm_ids、对应的操作shm_ops以及对应的参数shm_params。
如果key设置为IPC_PRIVATE则永远创建新的如果不是的话就会调用ipcget_public。ipcget的具体代码如下
```
int ipcget(struct ipc_namespace *ns, struct ipc_ids *ids,
const struct ipc_ops *ops, struct ipc_params *params)
{
if (params-&gt;key == IPC_PRIVATE)
return ipcget_new(ns, ids, ops, params);
else
return ipcget_public(ns, ids, ops, params);
}
static int ipcget_public(struct ipc_namespace *ns, struct ipc_ids *ids, const struct ipc_ops *ops, struct ipc_params *params)
{
struct kern_ipc_perm *ipcp;
int flg = params-&gt;flg;
int err;
ipcp = ipc_findkey(ids, params-&gt;key);
if (ipcp == NULL) {
if (!(flg &amp; IPC_CREAT))
err = -ENOENT;
else
err = ops-&gt;getnew(ns, params);
} else {
if (flg &amp; IPC_CREAT &amp;&amp; flg &amp; IPC_EXCL)
err = -EEXIST;
else {
err = 0;
if (ops-&gt;more_checks)
err = ops-&gt;more_checks(ipcp, params);
......
}
}
return err;
}
```
在ipcget_public中我们会按照key去查找struct kern_ipc_perm。如果没有找到那就看是否设置了IPC_CREAT如果设置了就创建一个新的。如果找到了就将对应的id返回。
我们这里重点看如何按照参数shm_ops创建新的共享内存会调用newseg。
```
static int newseg(struct ipc_namespace *ns, struct ipc_params *params)
{
key_t key = params-&gt;key;
int shmflg = params-&gt;flg;
size_t size = params-&gt;u.size;
int error;
struct shmid_kernel *shp;
size_t numpages = (size + PAGE_SIZE - 1) &gt;&gt; PAGE_SHIFT;
struct file *file;
char name[13];
vm_flags_t acctflag = 0;
......
shp = kvmalloc(sizeof(*shp), GFP_KERNEL);
......
shp-&gt;shm_perm.key = key;
shp-&gt;shm_perm.mode = (shmflg &amp; S_IRWXUGO);
shp-&gt;mlock_user = NULL;
shp-&gt;shm_perm.security = NULL;
......
file = shmem_kernel_file_setup(name, size, acctflag);
......
shp-&gt;shm_cprid = task_tgid_vnr(current);
shp-&gt;shm_lprid = 0;
shp-&gt;shm_atim = shp-&gt;shm_dtim = 0;
shp-&gt;shm_ctim = get_seconds();
shp-&gt;shm_segsz = size;
shp-&gt;shm_nattch = 0;
shp-&gt;shm_file = file;
shp-&gt;shm_creator = current;
error = ipc_addid(&amp;shm_ids(ns), &amp;shp-&gt;shm_perm, ns-&gt;shm_ctlmni);
......
list_add(&amp;shp-&gt;shm_clist, &amp;current-&gt;sysvshm.shm_clist);
......
file_inode(file)-&gt;i_ino = shp-&gt;shm_perm.id;
ns-&gt;shm_tot += numpages;
error = shp-&gt;shm_perm.id;
......
return error;
}
```
**newseg函数的第一步通过kvmalloc在直接映射区分配一个struct shmid_kernel结构。**这个结构就是用来描述共享内存的。这个结构最开始就是上面说的struct kern_ipc_perm结构。接下来就是填充这个struct shmid_kernel结构例如key、权限等。
**newseg函数的第二步共享内存需要和文件进行关联**。**为什么要做这个呢?我们在讲内存映射的时候讲过,虚拟地址空间可以和物理内存关联,但是物理内存是某个进程独享的。虚拟地址空间也可以映射到一个文件,文件是可以跨进程共享的。
咱们这里的共享内存需要跨进程共享也应该借鉴文件映射的思路。只不过不应该映射一个硬盘上的文件而是映射到一个内存文件系统上的文件。mm/shmem.c里面就定义了这样一个基于内存的文件系统。这里你一定要注意区分shmem和shm的区别前者是一个文件系统后者是进程通信机制。
在系统初始化的时候shmem_init注册了shmem文件系统shmem_fs_type并且挂在到了shm_mnt下面。
```
int __init shmem_init(void)
{
int error;
error = shmem_init_inodecache();
error = register_filesystem(&amp;shmem_fs_type);
shm_mnt = kern_mount(&amp;shmem_fs_type);
......
return 0;
}
static struct file_system_type shmem_fs_type = {
.owner = THIS_MODULE,
.name = &quot;tmpfs&quot;,
.mount = shmem_mount,
.kill_sb = kill_litter_super,
.fs_flags = FS_USERNS_MOUNT,
};
```
接下来newseg函数会调用shmem_kernel_file_setup其实就是在shmem文件系统里面创建一个文件。
```
/**
* shmem_kernel_file_setup - get an unlinked file living in tmpfs which must be kernel internal.
* @name: name for dentry (to be seen in /proc/&lt;pid&gt;/maps
* @size: size to be set for the file
* @flags: VM_NORESERVE suppresses pre-accounting of the entire object size */
struct file *shmem_kernel_file_setup(const char *name, loff_t size, unsigned long flags)
{
return __shmem_file_setup(name, size, flags, S_PRIVATE);
}
static struct file *__shmem_file_setup(const char *name, loff_t size,
unsigned long flags, unsigned int i_flags)
{
struct file *res;
struct inode *inode;
struct path path;
struct super_block *sb;
struct qstr this;
......
this.name = name;
this.len = strlen(name);
this.hash = 0; /* will go */
sb = shm_mnt-&gt;mnt_sb;
path.mnt = mntget(shm_mnt);
path.dentry = d_alloc_pseudo(sb, &amp;this);
d_set_d_op(path.dentry, &amp;anon_ops);
......
inode = shmem_get_inode(sb, NULL, S_IFREG | S_IRWXUGO, 0, flags);
inode-&gt;i_flags |= i_flags;
d_instantiate(path.dentry, inode);
inode-&gt;i_size = size;
......
res = alloc_file(&amp;path, FMODE_WRITE | FMODE_READ,
&amp;shmem_file_operations);
return res;
}
```
__shmem_file_setup会创建新的shmem文件对应的dentry和inode并将它们两个关联起来然后分配一个struct file结构来表示新的shmem文件并且指向独特的shmem_file_operations。
```
static const struct file_operations shmem_file_operations = {
.mmap = shmem_mmap,
.get_unmapped_area = shmem_get_unmapped_area,
#ifdef CONFIG_TMPFS
.llseek = shmem_file_llseek,
.read_iter = shmem_file_read_iter,
.write_iter = generic_file_write_iter,
.fsync = noop_fsync,
.splice_read = generic_file_splice_read,
.splice_write = iter_file_splice_write,
.fallocate = shmem_fallocate,
#endif
};
```
**newseg函数的第三步通过ipc_addid将新创建的struct shmid_kernel结构挂到shm_ids里面的基数树上并返回相应的id并且将struct shmid_kernel挂到当前进程的sysvshm队列中。**
至此,共享内存的创建就完成了。
## 如何将共享内存映射到虚拟地址空间?
从上面的代码解析中我们知道共享内存的数据结构struct shmid_kernel是通过它的成员struct file *shm_file来管理内存文件系统shmem上的内存文件的。无论这个共享内存是否被映射shm_file都是存在的。
接下来我们要将共享内存映射到虚拟地址空间中。调用的是shmat对应的系统调用如下
```
SYSCALL_DEFINE3(shmat, int, shmid, char __user *, shmaddr, int, shmflg)
{
unsigned long ret;
long err;
err = do_shmat(shmid, shmaddr, shmflg, &amp;ret, SHMLBA);
force_successful_syscall_return();
return (long)ret;
}
long do_shmat(int shmid, char __user *shmaddr, int shmflg,
ulong *raddr, unsigned long shmlba)
{
struct shmid_kernel *shp;
unsigned long addr = (unsigned long)shmaddr;
unsigned long size;
struct file *file;
int err;
unsigned long flags = MAP_SHARED;
unsigned long prot;
int acc_mode;
struct ipc_namespace *ns;
struct shm_file_data *sfd;
struct path path;
fmode_t f_mode;
unsigned long populate = 0;
......
prot = PROT_READ | PROT_WRITE;
acc_mode = S_IRUGO | S_IWUGO;
f_mode = FMODE_READ | FMODE_WRITE;
......
ns = current-&gt;nsproxy-&gt;ipc_ns;
shp = shm_obtain_object_check(ns, shmid);
......
path = shp-&gt;shm_file-&gt;f_path;
path_get(&amp;path);
shp-&gt;shm_nattch++;
size = i_size_read(d_inode(path.dentry));
......
sfd = kzalloc(sizeof(*sfd), GFP_KERNEL);
......
file = alloc_file(&amp;path, f_mode,
is_file_hugepages(shp-&gt;shm_file) ?
&amp;shm_file_operations_huge :
&amp;shm_file_operations);
......
file-&gt;private_data = sfd;
file-&gt;f_mapping = shp-&gt;shm_file-&gt;f_mapping;
sfd-&gt;id = shp-&gt;shm_perm.id;
sfd-&gt;ns = get_ipc_ns(ns);
sfd-&gt;file = shp-&gt;shm_file;
sfd-&gt;vm_ops = NULL;
......
addr = do_mmap_pgoff(file, addr, size, prot, flags, 0, &amp;populate, NULL);
*raddr = addr;
err = 0;
......
return err;
}
```
在这个函数里面shm_obtain_object_check会通过共享内存的id在基数树中找到对应的struct shmid_kernel结构通过它找到shmem上的内存文件。
接下来我们要分配一个struct shm_file_data来表示这个内存文件。将shmem中指向内存文件的shm_file赋值给struct shm_file_data中的file成员。
然后我们创建了一个struct file指向的也是shmem中的内存文件。
为什么要再创建一个呢这两个的功能不同shmem中shm_file用于管理内存文件是一个中立的独立于任何一个进程的角色。而新创建的struct file是专门用于做内存映射的就像咱们在讲内存映射那一节讲过的一个硬盘上的文件要映射到虚拟地址空间中的时候需要在vm_area_struct里面有一个struct file *vm_file指向硬盘上的文件现在变成内存文件了但是这个结构还是不能少。
新创建的struct file的private_data指向struct shm_file_data这样内存映射那部分的数据结构就能够通过它来访问内存文件了。
新创建的struct file的file_operations也发生了变化变成了shm_file_operations。
```
static const struct file_operations shm_file_operations = {
.mmap = shm_mmap,
.fsync = shm_fsync,
.release = shm_release,
.get_unmapped_area = shm_get_unmapped_area,
.llseek = noop_llseek,
.fallocate = shm_fallocate,
};
```
接下来do_mmap_pgoff函数我们遇到过原来映射硬盘上的文件的时候也是调用它。这里我们不再详细解析了。它会分配一个vm_area_struct指向虚拟地址空间中没有分配的区域它的vm_file指向这个内存文件然后它会调用shm_file_operations的mmap函数也即shm_mmap进行映射。
```
static int shm_mmap(struct file *file, struct vm_area_struct *vma)
{
struct shm_file_data *sfd = shm_file_data(file);
int ret;
ret = __shm_open(vma);
ret = call_mmap(sfd-&gt;file, vma);
sfd-&gt;vm_ops = vma-&gt;vm_ops;
vma-&gt;vm_ops = &amp;shm_vm_ops;
return 0;
}
```
shm_mmap中调用了shm_file_data中的file的mmap函数这次调用的是shmem_file_operations的mmap也即shmem_mmap。
```
static int shmem_mmap(struct file *file, struct vm_area_struct *vma)
{
file_accessed(file);
vma-&gt;vm_ops = &amp;shmem_vm_ops;
return 0;
}
```
这里面vm_area_struct的vm_ops指向shmem_vm_ops。等从call_mmap中返回之后shm_file_data的vm_ops指向了shmem_vm_ops而vm_area_struct的vm_ops改为指向shm_vm_ops。
我们来看一下shm_vm_ops和shmem_vm_ops的定义。
```
static const struct vm_operations_struct shm_vm_ops = {
.open = shm_open, /* callback for a new vm-area open */
.close = shm_close, /* callback for when the vm-area is released */
.fault = shm_fault,
};
static const struct vm_operations_struct shmem_vm_ops = {
.fault = shmem_fault,
.map_pages = filemap_map_pages,
};
```
它们里面最关键的就是fault函数也即访问虚拟内存的时候访问不到应该怎么办。
当访问不到的时候先调用vm_area_struct的vm_ops也即shm_vm_ops的fault函数shm_fault。然后它会转而调用shm_file_data的vm_ops也即shmem_vm_ops的fault函数shmem_fault。
```
static int shm_fault(struct vm_fault *vmf)
{
struct file *file = vmf-&gt;vma-&gt;vm_file;
struct shm_file_data *sfd = shm_file_data(file);
return sfd-&gt;vm_ops-&gt;fault(vmf);
}
```
虽然基于内存的文件系统已经为这个内存文件分配了inode但是内存也却是一点儿都没分配只有在发生缺页异常的时候才进行分配。
```
static int shmem_fault(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf-&gt;vma;
struct inode *inode = file_inode(vma-&gt;vm_file);
gfp_t gfp = mapping_gfp_mask(inode-&gt;i_mapping);
......
error = shmem_getpage_gfp(inode, vmf-&gt;pgoff, &amp;vmf-&gt;page, sgp,
gfp, vma, vmf, &amp;ret);
......
}
/*
* shmem_getpage_gfp - find page in cache, or get from swap, or allocate
*
* If we allocate a new one we do not mark it dirty. That's up to the
* vm. If we swap it in we mark it dirty since we also free the swap
* entry since a page cannot live in both the swap and page cache.
*
* fault_mm and fault_type are only supplied by shmem_fault:
* otherwise they are NULL.
*/
static int shmem_getpage_gfp(struct inode *inode, pgoff_t index,
struct page **pagep, enum sgp_type sgp, gfp_t gfp,
struct vm_area_struct *vma, struct vm_fault *vmf, int *fault_type)
{
......
page = shmem_alloc_and_acct_page(gfp, info, sbinfo,
index, false);
......
}
```
shmem_fault会调用shmem_getpage_gfp在page cache和swap中找一个空闲页如果找不到就通过shmem_alloc_and_acct_page分配一个新的页他最终会调用内存管理系统的alloc_page_vma在物理内存中分配一个页。
至此,共享内存才真的映射到了虚拟地址空间中,进程可以像访问本地内存一样访问共享内存。
## 总结时刻
我们来总结一下共享内存的创建和映射过程。
1. 调用shmget创建共享内存。
1. 先通过ipc_findkey在基数树中查找key对应的共享内存对象shmid_kernel是否已经被创建过如果已经被创建就会被查询出来例如producer创建过在consumer中就会查询出来。
1. 如果共享内存没有被创建过则调用shm_ops的newseg方法创建一个共享内存对象shmid_kernel。例如在producer中就会新建。
1. 在shmem文件系统里面创建一个文件共享内存对象shmid_kernel指向这个文件这个文件用struct file表示我们姑且称它为file1。
1. 调用shmat将共享内存映射到虚拟地址空间。
1. shm_obtain_object_check先从基数树里面找到shmid_kernel对象。
1. 创建用于内存映射到文件的file和shm_file_data这里的struct file我们姑且称为file2。
1. 关联内存区域vm_area_struct和用于内存映射到文件的file也即file2调用file2的mmap函数。
1. file2的mmap函数shm_mmap会调用file1的mmap函数shmem_mmap设置shm_file_data和vm_area_struct的vm_ops。
1. 内存映射完毕之后其实并没有真的分配物理内存当访问内存的时候会触发缺页异常do_page_fault。
1. vm_area_struct的vm_ops的shm_fault会调用shm_file_data的vm_ops的shmem_fault。
1. 在page cache中找一个空闲页或者创建一个空闲页。
<img src="https://static001.geekbang.org/resource/image/20/51/20e8f4e69d47b7469f374bc9fbcf7251.png" alt="">
## 课堂练习
在这里我们只分析了shm_ids的结构消息队列的程序我们写过了但是msg_ids的结构没有解析你可以试着解析一下。
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,539 @@
<audio id="audio" title="42 | IPC不同项目组之间抢资源如何协调" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/09/32/097590a47025a46e19b1551183297c32.mp3"></audio>
IPC这块的内容比较多为了让你能够更好地理解我分成了三节来讲。前面我们解析完了共享内存的内核机制后今天我们来看最后一部分信号量的内核机制。
首先我们需要创建一个信号量调用的是系统调用semget。代码如下
```
SYSCALL_DEFINE3(semget, key_t, key, int, nsems, int, semflg)
{
struct ipc_namespace *ns;
static const struct ipc_ops sem_ops = {
.getnew = newary,
.associate = sem_security,
.more_checks = sem_more_checks,
};
struct ipc_params sem_params;
ns = current-&gt;nsproxy-&gt;ipc_ns;
sem_params.key = key;
sem_params.flg = semflg;
sem_params.u.nsems = nsems;
return ipcget(ns, &amp;sem_ids(ns), &amp;sem_ops, &amp;sem_params);
}
```
我们解析过了共享内存再看信号量就顺畅很多了。这里同样调用了抽象的ipcget参数分别为信号量对应的sem_ids、对应的操作sem_ops以及对应的参数sem_params。
ipcget的代码我们已经解析过了。如果key设置为IPC_PRIVATE则永远创建新的如果不是的话就会调用ipcget_public。
在ipcget_public中我们能会按照key去查找struct kern_ipc_perm。如果没有找到那就看看是否设置了IPC_CREAT。如果设置了就创建一个新的。如果找到了就将对应的id返回。
我们这里重点看如何按照参数sem_ops创建新的信号量会调用newary。
```
static int newary(struct ipc_namespace *ns, struct ipc_params *params)
{
int retval;
struct sem_array *sma;
key_t key = params-&gt;key;
int nsems = params-&gt;u.nsems;
int semflg = params-&gt;flg;
int i;
......
sma = sem_alloc(nsems);
......
sma-&gt;sem_perm.mode = (semflg &amp; S_IRWXUGO);
sma-&gt;sem_perm.key = key;
sma-&gt;sem_perm.security = NULL;
......
for (i = 0; i &lt; nsems; i++) {
INIT_LIST_HEAD(&amp;sma-&gt;sems[i].pending_alter);
INIT_LIST_HEAD(&amp;sma-&gt;sems[i].pending_const);
spin_lock_init(&amp;sma-&gt;sems[i].lock);
}
sma-&gt;complex_count = 0;
sma-&gt;use_global_lock = USE_GLOBAL_LOCK_HYSTERESIS;
INIT_LIST_HEAD(&amp;sma-&gt;pending_alter);
INIT_LIST_HEAD(&amp;sma-&gt;pending_const);
INIT_LIST_HEAD(&amp;sma-&gt;list_id);
sma-&gt;sem_nsems = nsems;
sma-&gt;sem_ctime = get_seconds();
retval = ipc_addid(&amp;sem_ids(ns), &amp;sma-&gt;sem_perm, ns-&gt;sc_semmni);
......
ns-&gt;used_sems += nsems;
......
return sma-&gt;sem_perm.id;
}
```
newary函数的第一步通过kvmalloc在直接映射区分配一个struct sem_array结构。这个结构是用来描述信号量的这个结构最开始就是上面说的struct kern_ipc_perm结构。接下来就是填充这个struct sem_array结构例如key、权限等。
struct sem_array里有多个信号量放在struct sem sems[]数组里面在struct sem里面有当前的信号量的数值semval。
```
struct sem {
int semval; /* current value */
/*
* PID of the process that last modified the semaphore. For
* Linux, specifically these are:
* - semop
* - semctl, via SETVAL and SETALL.
* - at task exit when performing undo adjustments (see exit_sem).
*/
int sempid;
spinlock_t lock; /* spinlock for fine-grained semtimedop */
struct list_head pending_alter; /* pending single-sop operations that alter the semaphore */
struct list_head pending_const; /* pending single-sop operations that do not alter the semaphore*/
time_t sem_otime; /* candidate for sem_otime */
} ____cacheline_aligned_in_smp;
```
struct sem_array和struct sem各有一个链表struct list_head pending_alter分别表示对于整个信号量数组的修改和对于某个信号量的修改。
newary函数的第二步就是初始化这些链表。
newary函数的第三步通过ipc_addid将新创建的struct sem_array结构挂到sem_ids里面的基数树上并返回相应的id。
信号量创建的过程到此结束接下来我们来看如何通过semctl对信号量数组进行初始化。
```
SYSCALL_DEFINE4(semctl, int, semid, int, semnum, int, cmd, unsigned long, arg)
{
int version;
struct ipc_namespace *ns;
void __user *p = (void __user *)arg;
ns = current-&gt;nsproxy-&gt;ipc_ns;
switch (cmd) {
case IPC_INFO:
case SEM_INFO:
case IPC_STAT:
case SEM_STAT:
return semctl_nolock(ns, semid, cmd, version, p);
case GETALL:
case GETVAL:
case GETPID:
case GETNCNT:
case GETZCNT:
case SETALL:
return semctl_main(ns, semid, semnum, cmd, p);
case SETVAL:
return semctl_setval(ns, semid, semnum, arg);
case IPC_RMID:
case IPC_SET:
return semctl_down(ns, semid, cmd, version, p);
default:
return -EINVAL;
}
}
```
这里我们重点看SETALL操作调用的semctl_main函数以及SETVAL操作调用的semctl_setval函数。
对于SETALL操作来讲传进来的参数为union semun里面的unsigned short *array会设置整个信号量集合。
```
static int semctl_main(struct ipc_namespace *ns, int semid, int semnum,
int cmd, void __user *p)
{
struct sem_array *sma;
struct sem *curr;
int err, nsems;
ushort fast_sem_io[SEMMSL_FAST];
ushort *sem_io = fast_sem_io;
DEFINE_WAKE_Q(wake_q);
sma = sem_obtain_object_check(ns, semid);
nsems = sma-&gt;sem_nsems;
......
switch (cmd) {
......
case SETALL:
{
int i;
struct sem_undo *un;
......
if (copy_from_user(sem_io, p, nsems*sizeof(ushort))) {
......
}
......
for (i = 0; i &lt; nsems; i++) {
sma-&gt;sems[i].semval = sem_io[i];
sma-&gt;sems[i].sempid = task_tgid_vnr(current);
}
......
sma-&gt;sem_ctime = get_seconds();
/* maybe some queued-up processes were waiting for this */
do_smart_update(sma, NULL, 0, 0, &amp;wake_q);
err = 0;
goto out_unlock;
}
}
......
wake_up_q(&amp;wake_q);
......
}
```
在semctl_main函数中先是通过sem_obtain_object_check根据信号量集合的id在基数树里面找到struct sem_array对象发现如果是SETALL操作就将用户的参数中的unsigned short *array通过copy_from_user拷贝到内核里面的sem_io数组然后是一个循环对于信号量集合里面的每一个信号量设置semval以及修改这个信号量值的pid。
对于SETVAL操作来讲传进来的参数union semun里面的int val仅仅会设置某个信号量。
```
static int semctl_setval(struct ipc_namespace *ns, int semid, int semnum,
unsigned long arg)
{
struct sem_undo *un;
struct sem_array *sma;
struct sem *curr;
int err, val;
DEFINE_WAKE_Q(wake_q);
......
sma = sem_obtain_object_check(ns, semid);
......
curr = &amp;sma-&gt;sems[semnum];
......
curr-&gt;semval = val;
curr-&gt;sempid = task_tgid_vnr(current);
sma-&gt;sem_ctime = get_seconds();
/* maybe some queued-up processes were waiting for this */
do_smart_update(sma, NULL, 0, 0, &amp;wake_q);
......
wake_up_q(&amp;wake_q);
return 0;
}
```
在semctl_setval函数中我们先是通过sem_obtain_object_check根据信号量集合的id在基数树里面找到struct sem_array对象对于SETVAL操作直接根据参数中的val设置semval以及修改这个信号量值的pid。
至此信号量数组初始化完毕。接下来我们来看P操作和V操作。无论是P操作还是V操作都是调用semop系统调用。
```
SYSCALL_DEFINE3(semop, int, semid, struct sembuf __user *, tsops,
unsigned, nsops)
{
return sys_semtimedop(semid, tsops, nsops, NULL);
}
SYSCALL_DEFINE4(semtimedop, int, semid, struct sembuf __user *, tsops,
unsigned, nsops, const struct timespec __user *, timeout)
{
int error = -EINVAL;
struct sem_array *sma;
struct sembuf fast_sops[SEMOPM_FAST];
struct sembuf *sops = fast_sops, *sop;
struct sem_undo *un;
int max, locknum;
bool undos = false, alter = false, dupsop = false;
struct sem_queue queue;
unsigned long dup = 0, jiffies_left = 0;
struct ipc_namespace *ns;
ns = current-&gt;nsproxy-&gt;ipc_ns;
......
if (copy_from_user(sops, tsops, nsops * sizeof(*tsops))) {
error = -EFAULT;
goto out_free;
}
if (timeout) {
struct timespec _timeout;
if (copy_from_user(&amp;_timeout, timeout, sizeof(*timeout))) {
}
jiffies_left = timespec_to_jiffies(&amp;_timeout);
}
......
/* On success, find_alloc_undo takes the rcu_read_lock */
un = find_alloc_undo(ns, semid);
......
sma = sem_obtain_object_check(ns, semid);
......
queue.sops = sops;
queue.nsops = nsops;
queue.undo = un;
queue.pid = task_tgid_vnr(current);
queue.alter = alter;
queue.dupsop = dupsop;
error = perform_atomic_semop(sma, &amp;queue);
if (error == 0) { /* non-blocking succesfull path */
DEFINE_WAKE_Q(wake_q);
......
do_smart_update(sma, sops, nsops, 1, &amp;wake_q);
......
wake_up_q(&amp;wake_q);
goto out_free;
}
/*
* We need to sleep on this operation, so we put the current
* task into the pending queue and go to sleep.
*/
if (nsops == 1) {
struct sem *curr;
curr = &amp;sma-&gt;sems[sops-&gt;sem_num];
......
list_add_tail(&amp;queue.list,
&amp;curr-&gt;pending_alter);
......
} else {
......
list_add_tail(&amp;queue.list, &amp;sma-&gt;pending_alter);
......
}
do {
queue.status = -EINTR;
queue.sleeper = current;
__set_current_state(TASK_INTERRUPTIBLE);
if (timeout)
jiffies_left = schedule_timeout(jiffies_left);
else
schedule();
......
/*
* If an interrupt occurred we have to clean up the queue.
*/
if (timeout &amp;&amp; jiffies_left == 0)
error = -EAGAIN;
} while (error == -EINTR &amp;&amp; !signal_pending(current)); /* spurious */
......
}
```
semop会调用semtimedop这是一个非常复杂的函数。
semtimedop做的第一件事情就是将用户的参数例如对于信号量的操作struct sembuf拷贝到内核里面来。另外如果是P操作很可能让进程进入等待状态是否要为这个等待状态设置一个超时timeout也是一个参数会把它变成时钟的滴答数目。
semtimedop做的第二件事情是通过sem_obtain_object_check根据信号量集合的id获得struct sem_array然后创建一个struct sem_queue表示当前的信号量操作。为什么叫queue呢因为这个操作可能马上就能完成也可能因为无法获取信号量不能完成不能完成的话就只好排列到队列上等待信号量满足条件的时候。semtimedop会调用perform_atomic_semop在实施信号量操作。
```
static int perform_atomic_semop(struct sem_array *sma, struct sem_queue *q)
{
int result, sem_op, nsops;
struct sembuf *sop;
struct sem *curr;
struct sembuf *sops;
struct sem_undo *un;
sops = q-&gt;sops;
nsops = q-&gt;nsops;
un = q-&gt;undo;
for (sop = sops; sop &lt; sops + nsops; sop++) {
curr = &amp;sma-&gt;sems[sop-&gt;sem_num];
sem_op = sop-&gt;sem_op;
result = curr-&gt;semval;
......
result += sem_op;
if (result &lt; 0)
goto would_block;
......
if (sop-&gt;sem_flg &amp; SEM_UNDO) {
int undo = un-&gt;semadj[sop-&gt;sem_num] - sem_op;
.....
}
}
for (sop = sops; sop &lt; sops + nsops; sop++) {
curr = &amp;sma-&gt;sems[sop-&gt;sem_num];
sem_op = sop-&gt;sem_op;
result = curr-&gt;semval;
if (sop-&gt;sem_flg &amp; SEM_UNDO) {
int undo = un-&gt;semadj[sop-&gt;sem_num] - sem_op;
un-&gt;semadj[sop-&gt;sem_num] = undo;
}
curr-&gt;semval += sem_op;
curr-&gt;sempid = q-&gt;pid;
}
return 0;
would_block:
q-&gt;blocking = sop;
return sop-&gt;sem_flg &amp; IPC_NOWAIT ? -EAGAIN : 1;
}
```
在perform_atomic_semop函数中对于所有信号量操作都进行两次循环。在第一次循环中如果发现计算出的result小于0则说明必须等待于是跳到would_block中设置q-&gt;blocking = sop表示这个queue是block在这个操作上然后如果需要等待则返回1。如果第一次循环中发现无需等待则第二个循环实施所有的信号量操作将信号量的值设置为新的值并且返回0。
接下来我们回到semtimedop来看它干的第三件事情就是如果需要等待应该怎么办
如果需要等待则要区分刚才的对于信号量的操作是对一个信号量的还是对于整个信号量集合的。如果是对于一个信号量的那我们就将queue挂到这个信号量的pending_alter中如果是对于整个信号量集合的那我们就将queue挂到整个信号量集合的pending_alter中。
接下来的do-while循环就是要开始等待了。如果等待没有时间限制则调用schedule让出CPU如果等待有时间限制则调用schedule_timeout让出CPU过一段时间还回来。当回来的时候判断是否等待超时如果没有等待超时则进入下一轮循环再次等待如果超时则退出循环返回错误。在让出CPU的时候设置进程的状态为TASK_INTERRUPTIBLE并且循环的结束会通过signal_pending查看是否收到过信号这说明这个等待信号量的进程是可以被信号中断的也即一个等待信号量的进程是可以通过kill杀掉的。
我们再来看semtimedop要做的第四件事情如果不需要等待应该怎么办
如果不需要等待就说明对于信号量的操作完成了也改变了信号量的值。接下来就是一个标准流程。我们通过DEFINE_WAKE_Q(wake_q)声明一个wake_q调用do_smart_update看这次对于信号量的值的改变可以影响并可以激活等待队列中的哪些struct sem_queue然后把它们都放在wake_q里面调用wake_up_q唤醒这些进程。其实所有的对于信号量的值的修改都会涉及这三个操作如果你回过头去仔细看SETALL和SETVAL操作在设置完毕信号量之后也是这三个操作。
我们来看do_smart_update是如何实现的。do_smart_update会调用update_queue。
```
static int update_queue(struct sem_array *sma, int semnum, struct wake_q_head *wake_q)
{
struct sem_queue *q, *tmp;
struct list_head *pending_list;
int semop_completed = 0;
if (semnum == -1)
pending_list = &amp;sma-&gt;pending_alter;
else
pending_list = &amp;sma-&gt;sems[semnum].pending_alter;
again:
list_for_each_entry_safe(q, tmp, pending_list, list) {
int error, restart;
......
error = perform_atomic_semop(sma, q);
/* Does q-&gt;sleeper still need to sleep? */
if (error &gt; 0)
continue;
unlink_queue(sma, q);
......
wake_up_sem_queue_prepare(q, error, wake_q);
......
}
return semop_completed;
}
static inline void wake_up_sem_queue_prepare(struct sem_queue *q, int error,
struct wake_q_head *wake_q)
{
wake_q_add(wake_q, q-&gt;sleeper);
......
}
```
update_queue会依次循环整个信号量集合的等待队列pending_alter或者某个信号量的等待队列。试图在信号量的值变了的情况下再次尝试perform_atomic_semop进行信号量操作。如果不成功则尝试队列中的下一个如果尝试成功则调用unlink_queue从队列上取下来然后调用wake_up_sem_queue_prepare将q-&gt;sleeper加到wake_q上去。q-&gt;sleeper是一个task_struct是等待在这个信号量操作上的进程。
接下来wake_up_q就依次唤醒wake_q上的所有task_struct调用的是我们在进程调度那一节学过的wake_up_process方法。
```
void wake_up_q(struct wake_q_head *head)
{
struct wake_q_node *node = head-&gt;first;
while (node != WAKE_Q_TAIL) {
struct task_struct *task;
task = container_of(node, struct task_struct, wake_q);
node = node-&gt;next;
task-&gt;wake_q.next = NULL;
wake_up_process(task);
put_task_struct(task);
}
}
```
至此,对于信号量的主流操作都解析完毕了。
其实还有一点需要强调一下信号量是一个整个Linux可见的全局资源而不像咱们在线程同步那一节讲过的都是某个进程独占的资源好处是可以跨进程通信坏处就是如果一个进程通过P操作拿到了一个信号量但是不幸异常退出了如果没有来得及归还这个信号量可能所有其他的进程都阻塞了。
那怎么办呢Linux有一种机制叫SEM_UNDO也即每一个semop操作都会保存一个反向struct sem_undo操作当因为某个进程异常退出的时候这个进程做的所有的操作都会回退从而保证其他进程可以正常工作。
如果你回头看我们写的程序里面的semaphore_p函数和semaphore_v函数都把sem_flg设置为SEM_UNDO就是这个作用。
等待队列上的每一个struct sem_queue都有一个struct sem_undo以此来表示这次操作的反向操作。
```
struct sem_queue {
struct list_head list; /* queue of pending operations */
struct task_struct *sleeper; /* this process */
struct sem_undo *undo; /* undo structure */
int pid; /* process id of requesting process */
int status; /* completion status of operation */
struct sembuf *sops; /* array of pending operations */
struct sembuf *blocking; /* the operation that blocked */
int nsops; /* number of operations */
bool alter; /* does *sops alter the array? */
bool dupsop; /* sops on more than one sem_num */
};
```
在进程的task_struct里面对于信号量有一个成员struct sysv_sem里面是一个struct sem_undo_list将这个进程所有的semop所带来的undo操作都串起来。
```
struct task_struct {
......
struct sysv_sem sysvsem;
......
}
struct sysv_sem {
struct sem_undo_list *undo_list;
};
struct sem_undo {
struct list_head list_proc; /* per-process list: *
* all undos from one process
* rcu protected */
struct rcu_head rcu; /* rcu struct for sem_undo */
struct sem_undo_list *ulp; /* back ptr to sem_undo_list */
struct list_head list_id; /* per semaphore array list:
* all undos for one array */
int semid; /* semaphore set identifier */
short *semadj; /* array of adjustments */
/* one per semaphore */
};
struct sem_undo_list {
atomic_t refcnt;
spinlock_t lock;
struct list_head list_proc;
};
```
为了让你更清楚地理解struct sem_undo的原理我们这里举一个例子。
假设我们创建了两个信号量集合。一个叫semaphore1它包含三个信号量初始化值为3另一个叫semaphore2它包含4个信号量初始化值都为4。初始化时候的信号量以及undo结构里面的值如图中(1)标号所示。
<img src="https://static001.geekbang.org/resource/image/03/d6/0352227c5f49d194b6094f229220cdd6.png" alt="">
首先我们来看进程1。我们调用semop将semaphore1的三个信号量的值分别加1、加2和减3从而信号量的值变为4,5,0。于是在semaphore1和进程1链表交汇的undo结构里面填写-1,-2,+3是semop操作的反向操作如图中(2)标号所示。
然后我们来看进程2。我们调用semop将semaphore1的三个信号量的值分别减3、加2和加1从而信号量的值变为1、7、1。于是在semaphore1和进程2链表交汇的undo结构里面填写+3、-2、-1是semop操作的反向操作如图中(3)标号所示。
然后我们接着看进程2。我们调用semop将semaphore2的四个信号量的值分别减3、加1、加4和减1从而信号量的值变为1、5、8、3。于是在semaphore2和进程2链表交汇的undo结构里面填写+3、-1、-4、+1是semop操作的反向操作如图中(4)标号所示。
然后我们再来看进程1。我们调用semop将semaphore2的四个信号量的值分别减1、减4、减5和加2从而信号量的值变为0、1、3、5。于是在semaphore2和进程1链表交汇的undo结构里面填写+1、+4、+5、-2是semop操作的反向操作如图中(5)标号所示。
从这个例子可以看出无论哪个进程异常退出只要将undo结构里面的值加回当前信号量的值就能够得到正确的信号量的值不会因为一个进程退出导致信号量的值处于不一致的状态。
## 总结时刻
信号量的机制也很复杂,我们对着下面这个图总结一下。
<img src="https://static001.geekbang.org/resource/image/60/7c/6028c83b0aa00e65916988911aa01b7c.png" alt="">
1. 调用semget创建信号量集合。
1. ipc_findkey会在基数树中根据key查找信号量集合sem_array对象。如果已经被创建就会被查询出来。例如producer被创建过在consumer中就会查询出来。
1. 如果信号量集合没有被创建过则调用sem_ops的newary方法创建一个信号量集合对象sem_array。例如在producer中就会新建。
1. 调用semctl(SETALL)初始化信号量。
1. sem_obtain_object_check先从基数树里面找到sem_array对象。
1. 根据用户指定的信号量数组初始化信号量集合也即初始化sem_array对象的struct sem sems[]成员。
1. 调用semop操作信号量。
1. 创建信号量操作结构sem_queue放入队列。
1. 创建undo结构放入链表。
## 课堂练习
现在我们的共享内存、信号量、消息队列都讲完了你是不是觉得它们的API非常相似。为了方便记忆你可以自己整理一个表格列一下这三种进程间通信机制、行为创建xxxget、使用、控制xxxctl、对应的API和系统调用。
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。