目录
1.简述
1.1进程间通信目的
数据传输:一个进程需要将它的数据发送给另一个进程
资源共享:多个进程之间共享同样的资源。
通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程))。
进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
1.2前提
进程间通讯,首先需要有一个可以共享的用于交换数据的空间(不能是某个进程自己开辟的,这样其他进程能够访问,就破坏了进程的独立性)。因此这块空间是由os提供并管理的。
os提供的空间有不同的样式,就决定了不同的通信方式。
1.3发展
管道
SystemV进程间通信
POSIX进程间通信
1.4分类
管道:
匿名管道pipe
命名管道
System V IPC:
SystemV消息队列
SystemV共享内存
SystemV信号量
POSIX IPC:
消息队列
共享内存
信号量
互斥量
条件变量
读写锁
2.管道
管道是Unix中最古老的进程间通信的形式。
我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”比如 我们在linux中使用的ls . | wc -l 命令。就是一个典型的管道应用,用户层ls .的结果本来是从标准输出中反馈到屏幕上给用户看的,但是这里将标准输出的内容放入了内核的管道中,管道的内容又作为了wc -l的标准输入接受的内容,然后再把wc -l的结果以标准输出的形式反馈给用户。
管道,就是基于文件的,让不同进程看到同一份资源的通信方式。
3.匿名管道
#include <unistd.h> 功能:创建一无名管道 原型 int pipe(int fd[2]); 参数 fd:文件描述符数组,其中fd[0]表示读端,fd[1]表示写端 返回值:成功返回0,失败返回-1,错误码也被设置这个工作原理跟前面的例子很像,只是这里是在fd[0]和fd[1]两个文件描述符对应struct file之间以管道的方式传输数据。成功后,从fd[0]可以读取到管道文件的内容,从fd[1]可以写入内容到管道文件里。
这里的fd是输出型参数,pipe这个系统调用,会在内存中创建一个 ‘不需要向磁盘刷新,也不存在于磁盘’上的文件,这个文件就是管道,而且这个文件没有名字,只能通过输出型参数的fd来访问,所以叫做匿名管道。
核心其实os还是创建一个缓冲区,然后该进程有2个fd分别对应 以r和w的形式指向了这个缓冲区的2个file对象。
因为匿名管道的通信依赖于父子进程的继承,所以,匿名管道只能在具有血缘关系的父子父孙等进程间通信,常用于父子进程。
3.1例子
这个是基础的验证管道间数据通信的代码。
例子:从键盘读取数据,写入管道,读取管道,写到屏幕 #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> int main( void ) { int fds[2]; char buf[100]; int len; if ( pipe(fds) == -1 ) perror("make pipe"),exit(1); // read from stdin while ( fgets(buf, 100, stdin) ){ len = strlen(buf); // write into pipe if ( write(fds[1], buf, len) != len ) { perror("write to pipe"); break; } memset(buf, 0x00, sizeof(buf)); // read from pipe if ( (len=read(fds[0], buf, 100)) == -1 ) { perror("read from pipe"); break; } // write to stdout if ( write(1, buf, len) != len ) { perror("write to stdout"); break; } } }
3.2fork来共享管道的原理
下面的通过普通文件来解释管道的原理
本质上就是父进程以读和写的方式打开同一个文件,产生2个fd。然后fork之后,子进程也有对应的2个fd,因为文件struct file是由os管理的,父子进程不会拷贝file,只会拷贝struct files_struct结构体,所以父子进程的4个fd,指向的都是同一个文件,用的是同一个内核缓冲区(这样就做到了不同进程看到同一个资源,甚至可以通过系统调用读取和写入这个资源,以达到通信目的)。
管道只能被设计成单向通信,所以父子进程也不能都保留2个fd,各需要关闭一个fd,达到其中一个只能写入,一个只能读取。
当我们用父进程fd[0]=open("文件名",r),fd[1]=open("文件名",w)之后。fd[0]代表读取端,fd[1]代表写入端,然后再fork。这时候代码共享,数据写时拷贝,意味着子进程也有fd[0]和fd[1]。注意,管道是内核管理的,也就是说父子进程此时使用的管道文件是同一个,因此只要将父进程的fd[1]关掉,子进程的fd[0]关掉。这样就形成了父进程从fd[0]读取管道内容,子进程从fd[1]写入内容到管道中的一个循环,也就是一个通信。
另外,单独关闭fd,并不会导致file对象被释放,因为struct file内部也有维护引用计数,当多个fd同时指向该file对象时,单个fd close,只会让file的引用计数--,只有引用计数为0才会释放文件。所以父子进程的读和写2个file都不会因为单独关闭fd而被释放。总结一下,就是同一个file可以被多个进程或指针指向同一个文件。
关于为什么一定要让父进程r和w,而不是父进程r,子进程w。以下是AI给出的答案,我觉得还不错。
下面是对os提供的管道文件的原理。
因此,看待管道跟看待文件一样,使用也是一致的,符合linux下一切皆文件的思想。
3.3测试&&多种情况&&特征
写端不是覆盖式写入而是追加式写入,读端读取的数据可以被覆盖,没读取的内容不能被覆盖,写端必须有空间才会写,读端必须有数据才能读,有明显的先后执行顺序。
第一种情况:管道内部没有数据(没写入)&&写端不关闭自己的写端文件fd,读端就要阻塞等待,直到管道有数据。
第二种情况:管道内部被写满(没读取)&&读端不关闭自己的fd,写端写满之后,就要阻塞等待,直到读端把数据读走。(可以验证,一般来说管道文件是64KB,65536字节作业,不同系统可能有差异)
第三种情况:对于写端而言:不写了&&关闭了写端fd,读端会将pipe中的数据读完,最后读到 返回值为0,表示读结束,类似读到了文件的结尾。
第四种情况:读端不读&&关闭fd,写端仍在写,os会直接终止写端的进程,通过发送信号13 SIGPIPE 来让进程产生异常,然后终止进程(验证方法可以让子进程写入,父进程读取,然后让父进程停止写入,关闭fd,让子进程继续写入,然后父进程接收到子进程的退出码(操作可参考进程的控制-优快云博客),通过退出码即可知道子进程是因什么原因退出的,验证结果应该是收到了13号信号)
特征一:自带同步机制
特征二:血缘关系进程进行通信,常见于父子
特征三:pipe是面向字节流的 (写入和读取没有必然关系,同一段数据,写入可能写了很多次,并不意味着读取的时候也要读取那么多次,可以一次性读取完)
特征四:连接管道的2个进程都终止了,那么管道文件自动释放,因为管道也是文件,文件的生命周期是随着指向其的进程数量来决定的(当没有任何一个进程的文件描述符表关联了这个文件,那么这个文件的引用计数为0,就会被os自动释放掉)
特征五:在下面的特点中的最后一条。
部分情况的测试代码
#include<stdio.h> #include<stdlib.h> #include<unistd.h> #include<string.h> #include<sys/types.h> #include<sys/wait.h> void writer(int wfd){ const char *str="I am child"; char buffer[128]; int cnt=0; pid_t pid=getpid(); while(1){ //sleep(5); //加入这个后,会出现第一种情况 snprintf(buffer,sizeof(buffer),"message: %s,pid: %d,cnt: %d\n",str,pid,cnt);//把格式化后的内容写入buffer write(wfd,buffer,strlen(buffer));//不+1,不写入\0 //第二种情况 // char c='b';//上面的写入注释掉,保留这里的一个一个字符写入,下面的读取部分让其延迟。 // write(wfd,&c,1); //如上 cnt++; //printf("cnt: %d\n",cnt); sleep(1); } } void reader(int rfd){ char buffer[1024]; while(1){ //sleep(500); //配合上面的写入,可以实现第二种情况 int n=read(rfd,buffer,sizeof(buffer)-1); buffer[n]='\0'; printf("father get a message: %s",buffer); } } int main(){ int fds[2]; int n=pipe(fds); if(n<0)return 1; printf("fds[0]: %d, fds[1]: %d",fds[0],fds[1]);//很大可能是3 4 pid_t id=fork(); if(id==0){ //child 负责w close(fds[0]);//关闭读端 writer(fds[1]); //从写端写入数据 exit(0); } //father 负责r close(fds[1]);//关闭写端 reader(fds[0]); //从读端读取内容。 wait(NULL); return 0; }
3.3.1读写规则
补充上面内容。
概念理解
O_NONBLOCK是设置非阻塞I/O模式,enable是非阻塞,disable是阻塞。
PIPE_BUF不是管道文件大小,是另一个设置的大小,在linux是4096字节,可通过手册查看
原子性可以理解不会被其他进程或线程打扰,可以一次性完成的操作
当没有数据可读时
O_NONBLOCK disable:read调用阻塞,即进程暂停执行,一直等到有数据来到为止。
O_NONBLOCK enable:read调用返回-1,errno值为EAGAIN。
当管道满的时候
O_NONBLOCK disable:write调用阻塞,直到有进程读走数据
O_NONBLOCK enable:调用返回-1,errno值为EAGAIN
如果所有管道写端对应的文件描述符被关闭,则read返回0
如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能导致write进程退出
当要写入的数据量不大于PIPE_BUF时,linux将保证写入的原子性。
当要写入的数据量大于PIPE_BUF时,linux将不再保证写入的原子性。
3.3.2管道特点
补充上面的
只能用于具有共同祖先的进程(具有亲缘关系的进程)之间进行通信;通常,一个管道由一个进程创建,然后该进程调用fork,此后父、子进程之间就可应用该管道。
管道提供流式服务
一般而言,进程退出,管道释放,所以管道的生命周期随进程
一般而言,内核会对管道操作进行同步与互斥
管道是半双工的一种特殊情况,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道
3.4命令行的管道
ls /home | grep -v zsl | wc -l假设这样的一个命令。为了方便书写,我依次将这3个命令称为1、2、3号进程。
bash执行这个命令的之后,会分析命令内容,创建2个管道(暂且命名为1、2号管道),创建3个子进程。然后将1号进程的标准输出重定向到1号管道的写端,2号进程的标准输入重定向到1号管道的读端,标准输出重定向到2号管道的写端,3号进程的标准输入重定向到2号管道的读端。bash等待3个进程都结束运行,3号进程会将最终结果打印到屏幕上。
因此命令行的 | 依靠的就是匿名管道。
3.5进程池任务模拟
进程池跟内存池有点像,内存池是一次性申请大片空间,由应用层自己分配空间,减少系统开销(系统调用),进程池也是类似,提前创建一批进程(worker),由一个父进程或者说master,通过一一对应的管道传输数据,来控制这些进程执行什么任务,因为创建进程是有开销的,短时间工作量大,比长时间间断性工作好。
父进程不传内容到该管道,该管道对应的进程就会阻塞等待(前面的情况里有说)
父进程控制的时候还要考虑负载均衡,让所有worker均衡的执行任务。
具体代码和解释,可看gitee里面的代码。
4.命名管道
管道应用的一个限制就是只能在具有共同祖先(具有亲缘关系)的进程间通信。
如果我们想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它经常被称为命名管道。命名管道的管道原理,是基于没有关系的不同进程打开同一份文件,产生独立的2个struct file对象,但指向同一个缓冲区为前提,让不同进程能看到同一份资源
怎么保证不同进程可以打开同一个文件呢?同样的文件路径+文件名即可
命名管道是一种特殊类型的文件,虽然存在磁盘上,但不会加载内容,也不会被刷新,依靠上面的方法来找到。
另外从名字也可以看出,其实所谓的管道本身也是遵循fifo先进先出的原则。
4.1创建
第一种(命令行创建):
mkfifo filename
可以看到,管道文件是通过p来标识的。
我们也可以通过
第一个终端bash输入 echo test > fifo 这时会阻塞等待,等到有人读 第二个终端bash输入 cat < fifo 就会将test打印到第二个终端屏幕上 本质上仍然是2个进程(bash也是个程序,时时刻刻在运行)在通过fifo文件通信第二种(程序里通过函数):
int mkfifo(const char *filename,mode_t mode); 可以带路径也可以不带,默认文件名,那就是当前工作目录下 int main(int argc, char *argv[]) { mkfifo("p2", 0644); return 0; }
4.2匿名和命令的区别
匿名管道由pipe函数创建并打开。
命名管道由mkfifo函数创建,打开用open
FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在它们创建与打开的方式不同,一但这些工作完成之后,它们具有相同的语义。命令管道可以让不具有血缘关系的进程间进行通信。
4.3打开规则
如果当前打开操作是为读(读open处会阻塞)而打开FIFO时
O_NONBLOCK disable:阻塞直到有相应进程为写而打开该FIFO
O_NONBLOCK enable:立刻返回成功
如果当前打开操作是为写(写open处会阻塞)而打开FIFO时
O_NONBLOCK disable:阻塞直到有相应进程为读而打开该FIFO
O_NONBLOCK enable:立刻返回失败,错误码为ENXIO------------------------------------------------------------------
如果写端先结束进程,这时写端会一直把fifo文件读完,返回0之后结束读的操作。
大部分特性跟匿名管道差不多。
4.4实例
5.system V共享内存
系统v分 共享内存和消息队列以及信号量。
系统v是本地通信,用处不大,这里主要讲共享内存
消息队列和信号量有兴趣的可以去了解下。
后面写线程文章会提及的。
ipcs -a查看所有资源
ipcrm -a删除所有资源
注意所有删除,不管是命令行还是系统调用,都不能直接删除,如果链接数大于等于1,那么内核释放ipc资源是等链接数为0之后,其他情况下都是标记ipc资源为待删除,直到链接数为0。
共享内存区是最快的IPC(进程间通信)形式。一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数据,更形象的说共享内存被映射后,地址被进程地址空间记录,加上大小是确定的,所以不同进程可以看到同一片物理内存空间的存储值的任何变化,不需经过内核。
而管道是通过系统调用(可以发现,read和write这些系统调用,本质上就是拷贝),从用户层将数据拷贝到内核中的管道,再通过系统调用从管道拷贝到用户层,因此会慢上不少
对进程的关系不做要求。
os会先在物理内存开辟一段空间,将这段空间映射(通过页表)到进程A的地址空间的共享区里,返回这个空间起始虚拟地址给进程A;同理既然可以映射到A,自然也可以映射到B。因此2个进程都可以通过收到的虚拟地址来访问这个空间,来达到进程间通信的前提(看到同一份资源)
当这个共享内存不再需要使用的时候,需要移除映射,删除共享内存。
上面的os操作,在代码里就是一个个系统调用。
共享内存在内核中可以同时存在很多个,因此os也要管理这些共享内存,即先描述再组织。
为了让不同进程可以找到同一个共享内存,每个共享内存都一定有个标识符的属性
共享内存如果不主动释放,进程结束,共享内存依旧存在,因此就算进程结束了,只要能保证传一样的key值,那么就可以保证重新映射这个共享内存。
文件的生命周期随进程,共享内存的生命周期随内核(系统重启等才能被动结束)。
在内核角度,key是区分共享内存唯一性的标识,而shmid是用户,通过指令或代码对共享内存控制所使用的。类比的看,key可以看作是struct file*,而shmid可以看作是文件fd。
共享内存不提供任何进程间协同机制,读取只管读,写只管写,跟管道有很大区别,管道会让某一方进行阻塞等待。这也是共享内存的缺点,会导致通信的数据不一致,比如进程A想完整传输"wdadwada"一串字符,而写入内存的过程本身又是一个个字节写入的,加上没有协同,进程B看到"wda"就已经把值读走了。
为了解决这个缺点,一般来说是用systemV的信号量来实现协同,但我这篇文章不会详写关于信号量的内容,我下面的实例会利用管道的同步来取巧的完成协同(管道来同步,共享内存来通信,这样可以利用好双方的优点,共享内存速度快,管道有协同)。
5.1共享内存的数据结构
struct shmid_ds { struct ipc_perm shm_perm; /* 操作权限 */ int shm_segsz; /* 段大小(字节) */ __kernel_time_t shm_atime; /* 最后附加时间 */ __kernel_time_t shm_dtime; /* 最后分离时间 */ __kernel_time_t shm_ctime; /* 最后修改时间 */ __kernel_ipc_pid_t shm_cpid; /* 创建者PID */ __kernel_ipc_pid_t shm_lpid; /* 最后操作者PID */ unsigned short shm_nattch; /* 当前附加数量(挂在了几个进程中) */ unsigned short shm_unused; /* 保留字段(兼容性) */ void *shm_unused2; /* 保留字段(DIPC使用) */ void *shm_unused3; /* 未使用 */ };struct ipc_perm { key_t __key; /* IPC对象的键(标识符)*/ uid_t uid; /* 所有者的用户ID */ gid_t gid; /* 所有者的组ID */ uid_t cuid; /* 创建者的用户ID */ gid_t cgid; /* 创建者的组ID */ unsigned short mode; /* 权限模式(读/写/执行权限) */ // ... };struct ipc_perm 是一个专门用于System V IPC(进程间通信)权限管理的结构体。它定义了哪些用户和进程可以对共享内存段(以及其他IPC对象,如消息队列、信号量)进行何种操作。
5.2共享内存函数
头文件sys/ipc.h和sys/shm.h * shmget函数 * 功能:用来创建共享内存 * 原型:int shmget(key_t key, size_t size, int shmflg); * 参数: * key:共享内存的唯一标识符 * size:共享内存大小,单位字节 * shmflg:由九个权限标志构成,用法和创建文件时使用的mode模式标志一样 * - IPC_CREAT:共享内存不存在则创建并返回;已存在则获取并返回 * - IPC_CREAT | IPC_EXCL:共享内存不存在则创建并返回;已存在则出错返回 * IPC_EXCL不能单独使用,没有意义,上面的组合使用主要是确保是创建新的而不是获取已存在的 * 直接传0,即默认就是获取已存在的 * 还有别的,这里只说常见的标志 * 返回值:成功返回共享内存段的标识码(非负整数);失败返回-1,错误码被设置key是多少不重要,只要能标识唯一性即可,理论上可以随便填。
key_t ftok(const char *pathname, int proj_id); 这个系统调用就是生成key值的,里面有一套算法。函数的参数由用户随意指定,只要能保证最后生成一个唯一的即可。
为什么一定要让用户传key?因为2个进程在共享内存创建前是不能通信的,那么如果让os维护这个key,且让进程A创建共享内存,这时进程B怎么从os那知道该共享内存的key?不能知道,也就是说无法保证让进程B知道特定共享内存的key。因此2个进程调用上面的系统调用加上提前约定好的两个同样的参数,即可让2个进程获取到同样的key,这样2个进程AB就都能访问同一个共享内存了。注意,就算是这样,也是有机率生成的key跟内核中现有的key冲突,因此需要不停的调整参数注意,共享内存也是有权限的,参数也是shmflg
例如:
shmget(key_t key, size_t size, IPC_CREAT | IPC_EXCL | 0666);查看宏的定义就可以发现,ipc_creat这些,从低到高,占的是第4位,前3位是空着的,就是给权限留的。
注意,内核对共享内存的管理是以4KB为基本单位的,也就是说,假如size我们填了4099,那么os会分配8KB的空间给共享内存,但因为我们只申请了4099B,所以剩下的空间也无法使用,等于浪费,因此size的值都是n*4096。另外申请的内存空间都是连续的。
* shmat函数 * 功能:将共享内存段连接到进程地址空间 * 原型:void *shmat(int shmid, const void *shmaddr, int shmflg); * 参数: * shmid:共享内存标识 * shmaddr:指定连接的地址(进程地址空间中的虚拟地址) * shmflg:可能取值是SHM_RND和SHM_RDONLY,默认为0,即可读写 * 返回值:成功返回指向共享内存第一个节的指针(也就是共享内存在进程地址空间中的首地址); 失败返回(void *)-1 * * 说明: * - shmaddr为NULL时,核心自动选择一个地址 * - shmaddr不为NULL且shmflg无SHM_RND标记,则以shmaddr为连接地址 * - shmaddr不为NULL且设置了SHM_RND标记,则地址自动向下调整为SHMLBA的整数倍 * 公式:shmaddr - (shmaddr % SHMLBA) * - shmflg=SHM_RDONLY表示连接操作用来只读共享内存就是将创建好的共享内存,映射到进程的进程地址空间的共享区里。我们可以用返回的地址来直接访问共享内存。共享内存的大小,我们创建的时候就已经确定了,所以可以依靠首地址加偏移量,访问共享内存的每个地址。
进程退出,自动取消关联。
* shmdt函数 * 功能:将共享内存段与当前进程脱离 * 原型:int shmdt(const void *shmaddr); * 参数: * shmaddr:由shmat所返回的指针 * 返回值:成功返回0;失败返回-1 * 注意:将共享内存段与当前进程脱离不等于删除共享内存段
* shmctl函数 * 功能:用于控制共享内存 * 原型:int shmctl(int shmid, int cmd, struct shmid_ds *buf); * 参数: * shmid:由shmget返回的共享内存标识码 * cmd:将要采取的动作(常见三个可取值) * buf:指向保存共享内存模式状态和访问权限的数据结构 * 返回值:成功返回0;失败返回-1
ipcs可以看到消息队列,共享内存,信号量,-m可以指定看共享内存
ipcrm -m shmid 就可以在shell中删除指定shmid的共享内存。
注意,perms是权限。
nattch是共享内存关联了几个进程。
5.3实例
ShareMemory_IPC · 孟新大人/Study-Me - 码云 - 开源中国
6.system V消息队列(仅了解)
消息队列提供了一个从一个进程向另外一个进程发送一块数据的方法
每个数据块都被认为是有一个类型,接收者进程接收的数据块可以有不同的类型值
IPC资源必须删除,否则不会自动清除,除非重启,所以systemVIPC资源的生命周期随内核通信的进程,会共享一个队列(os提供),进程将数据块链入队列,为了让进程能够拿到不是自己发的数据块,所以必须有类型来区分。
创建消息队列,key和msgflag与共享内存创建时的key和shmflg一样,返回值是msqid。key的生成也可以参考共享内存
几乎跟共享内存一样。消息队列也有个结构来组织管理
这个就是数据块,注意,这个需要用户自己在代码里定义这个结构体,这里只是说明了大概要怎么定义。所以mtext这个数组的大小是我们自己定的。类型,比如定义个宏reader 1和writer 2
发送数据msgsnd,其中msgp就是要发生的数据块,msgsz就是数据块的大小,msgflg参考共享内存关联的函数,默认传0即可。
接受数据msgrcv,msgtyp传类型(比如reader)。
用shell命令查看消息队列,就是ipcs -q
删除就是ipcrm -q msqid
7.system V信号量(重在理论)
对共享资源的保护,在多执行流场景下是非常常见和重要的话题。我们之前学的管道是有保护的,保护操作由os负责(比如同步和互斥),共享内存是没有保护的。
信号量主要用于同步和互斥的。
7.1前情提要
多个执行流(进程),能看到的同一份公共资源:共享资源
被保护起来的共享资源叫做临界资源
保护的方式常见:互斥与同步
任何时刻,只允许一个执行流访问资源,叫做互斥
多个执行流,访问临界资源的时候,为了防止纯互质而导致进程饥饿(有的进程一直抢着去访问临界资源,虽然遵循了互斥,但也让别人一直不能访问了),所以让这些进程按一定的顺序性来访问临界资源(前提是安全,也就是互斥的情况下),这个顺序性叫做同步
系统中某些资源一次只允许一个进程使用,称这样的资源为临界资源或互斥资源。
在进程中涉及到互斥资源的程序段叫临界区。你写的代码=访问临界资源的代码(临界区)+不访问临界资源的代码(非临界区)
所谓的对共享资源进行保护,本质是对访问共享资源的代码进行保护原子性:操作对象的时候,只有2种状态,要么还没开始,要么已经结束。像初始化变量就是原子性操作。
7.2信号量
7.2.1理论
特性:
IPC资源必须删除,否则不会自动清除,除非重启,所以system V IPC资源的生命周期随内核
理解:
信号量是一个计数器,描述临界资源数量(有些资源可以被分成一份份,有些不能分)的计数器。进程需要申请信号量,申请成功了才能执行访问临界区的代码。申请的本质就是对公共资源的局部资源的预定,只要预定成功了,就算你不用,那也不会被别人拿走
当进程不需要这个资源了,需要释放信号量,这样这份资源就可以被其他进程申请了。
申请、访问、释放,就是临界区要做的,其他代码都是非临界区。
一个信号量就是对应一个资源(整体或者是分割前的整体),注意,信号量是几跟多个信号量是不同的概念,os允许进程一次申请多个信号量。
作用:
保护临界区
本质:
信号量本质是对资源的预订机制一个个进程想要拿到一份资源,不一定是现在就用上了,而是先预定了,只要预定了,那么可以保证在未来的某个时间当进程要使用这份资源的时候,可以马上使用。资源不会被多个进程预定,保证资源不被并发访问。
操作:
申请资源,计数器--,P操作。比如if(count>0)count-- else wait
释放资源,计数器++,V操作。比如count++。
资源一般情况下是以整体为单位被使用的(比如电影院只有一个座位)
资源特殊情况下,可以被分割成一份份(意味着当一份共享资源,但可以被切成一份份小资源,这样让所有进程都可以访问,却又不会冲突,电影院座位就可以看作一个小资源,电影是共享资源)。这种情况下,相比整体为单位,并发效率很好,要注意的是限制访问的进程数和合理的分配资源(分配资源由程序员编码实现,多线程部分再说)。
限制进程数:
注意,在多进程的场景下,普通的int变量不能实现信号量的效果,第一个理由:因为无法在进程间共享(父子进程也会出现写时拷贝),也就是说不能让多个进程看到同一份资源(计数器),所以信号量要想正常发挥作用,前提是让进程间看到同一份资源,这也就符合了进场间通信的前提,所以这时候就需要让os维护这个计数器资源,让所有进程都能看到这份资源。第二个理由:count++,count--这个操作不是原子的,转换成汇编是多条指令。
注意,信号量的操作,是需要程序员写的时候就遵循的,也就是说所有进程都需要遵守这个规则,这样才能不出现并发冲突的问题。
根据前面,信号量本身是由os维护,进程访问临界资源需要申请信号量,申请的前提是所有进程都能看到同一个信号量,也就是说这个信号量本身就是一个共享资源,而共享资源如果不被保护,就有并发冲突的风险,因此申请和释放信号量的操作必须是原子的,这样才能让进程申请和释放的操作不被打断,保护信号量。这里的申请和释放,就是pv操作。
信号量的初始值如果就是1,那就意味着这个资源是被当做了一个整体所使用,这种二元信号量,就是一把锁,也满足了互斥的要求。
----------------------------------------------------------------------------------------------------------
7.2.2接口
数据结构
key不用多说,返回值是信号量集标识符;nsems是想创建几个信号量,这个集合可以看作是个数组;
semnum是表示,要指定semid的信号集的哪一个信号量,第一个是0。cmd参考前面。
对semid信号量集的nsops个信号量进行sops的操作(保证原子性),nsops是sops数组有多少个元素。
其中struct sembuf是要求程序员自己定义的结构体,可以类比消息队列里面的数据块也是自己定义的。其中必须包含如下成员:
其中,sem_num是指明在信号量集中的下标,第一个是0;sem_op就是pv操作,-1是p,1是v。sem_flg默认0是阻塞。
ipcs -s 是返回系统中信号量集的信息
nsems就是该信号量集有多少个信号量。
ipcrm -s semid删除信号量集。
8.内核对IPC资源的组织管理
前面讲了共享内存,消息队列,信号量。这些都是需要os去管理的,这些资源也被称为IPC资源,IPC就是进程间通信英文首字母,Inter-Process-Communication。
我们前面看到,这些资源都是有各自的结构体的,但前面展示的,都是给用户看的结构体,就像是c语言的FILE类型。内核里的组织结构还没看到。
具体的,因为实在很复杂,这里只是语言描述下。内核中先将3种资源看作同一种,然后分别存入不同结构体来细分,这3种资源都有各自的内核级结构,sem_array,msg_queue,shmid_kernel。sem_ids,msg_ids,shmid_ids都是从这内核级的结构里拷贝部分属性。另外,如用户级结构中,第一个成员都是perm类型一样,内核里也有个kern_ipc_perm结构对应。
内核怎么把所有ipc看作相同的呢?就是利用这个perm。内核有个ipc_ids的结构,里面有个指针,类型是ipc_id_ary,这个类型里第一个元素是size表示有多少个ipc资源,第二个元素就是柔性数组(类型是kern_ipc_perm*),柔性数组里存的就是每个ipc资源的kern_ipc_perm成员的地址,而又由于kern_ipc_perm是ipc资源的第一个成员,所以地址值等同于ipc资源的地址,所以直接不需要考虑是哪种ipc资源,直接把创建的ipc资源首地址放入柔性数组即可。
os通过kern_ipc_perm类型的key成员,以对柔性数组里的元素进行增删查改,同时为了访问不同类型的ipc资源,通过“kern_ipc_perm是ipc资源的第一个成员,所以地址值等同于ipc资源的地址”,所以只要将这个kern_ipc_perm*强转成对应的ipc资源结构体即可,比如(msg_queue*)ipc[0]->q_time;---------这个思路正是一种多态,kern_ipc_perm看作是基类,不同的ipc资源类型(msg_queue、sem_array、shmid_kernel)就是子类。
还有一个重要问题,os怎么知道这些ipc资源的类型呢?是通过kern_ipc_perm结构体中的mode成员,这个成员使用方法参考前面那些接口的flg标记位,只要提前定义好宏之类的,就可以通过&运算判断是哪个类型,然后强转成相应的ipc资源即可。
另外,我们看到的各种id,比如shmid、msqid、semid,这些id其实就是数组的下标罢了,会随着ipc资源增多而增长,但不会一味的扩容增长,os自有一套维护的算法。
综上所述,os将ipc资源的管理看作对数组的增删查改。
从上面也可以看到,这种管理方式,其实跟linux的一切皆文件思想是违背的,跟文件描述符表是不关联的,这也就导致了兼容性不好,再加上现在还有很多的通信方案都是遵循一切皆文件,相比之下这个方案就没什么吸引力了。


下面是对os提供的管道文件的原理。 



















7444

被折叠的 条评论
为什么被折叠?



