第六章进程间通信
6.1进程间通信介绍
6.1.1进程间通信的概念
进程间通信简称IPC,进程间通信就是在不同进程之间传播或交换信息。
6.1.2进程间通信的目的
数据传输:一个进程需要将它的数据发送给另一个进程。
资源共享:多个进程之间共享同样的资源
通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件,比如进程终止时需要通知其父进程。(信号)
*进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
6.1.3进程间通信的本质
进程间通信的本质就是,让不同的进程看到同一份资源。
由于各个运行进程之间具有独立性,这个独立性主要体现在数据层面,而代码逻辑层面可以私有也可以共有(例如父子进程)因而各个进程之间要实现通信是非常困难的。
各个进程之间若是想实现通信,一定要借助第三方资源,这些进程就可以通过向这个第三方资源写入或是读取,进而实现进程之间的通信,这个第三发方资源实际上就是操作系统提供的一段内存区域。
因此,进程间通信的本质就是,让不同的进程看到同一块资源(内存,文件,内存缓冲等)。由于这份资源可以由操作系统中的不同模块提供,因此出现了不同的进程间的通信方式。
6.2进程间通信的分类
6.2.1常见的进程间通信方式简介
管道pipe:管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。
命名管道FiFO:有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。
消息队列:消息队列是由信息的链表,存放在内核中并有消息队列标识符标识。消息队列克服了信号传递信息少,管道只能承载无格式字节流以及缓冲区大小受限等缺点。
共享存储:共享内存就是映射一段能被其它进程所访问的内存,这段共享内存由一个进程
创建,但是多个进程都可以访问。共享内存是最快的IPC方式,它是针对其它进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步和通信。
套接字Socket:套解口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同及其间的进程通信。
信号( sinal):信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
6.3管道
6.3.1什么是管道
管道是UNIX中最古老的进程间通信的形式,我们把从一个进程连接到另一个进程的数据流称为一个“管道”
例如,统计我们当前使用云服务器上的登录用户个数。
其中,who命令和wc命令都是两个程序,当它们运行起来后就变成了两个进程,who进程通过标准输出将数据打到“管道”当中,wc进程再通过标准输入从“管道”当中读取数据,至此完成了数据的传输,进而完成数据的进一步加工处理。
注明:who命令用于查看当前云服务器的登录用户(一行显示一个用户),wc -l 用于统计当前的行数。
6.3.2匿名管道
匿名管道用于进程间通信,且仅限于本地关联进程之间的通信。
进程间通信的本质就是让不同的进程看到同一块资源,使用匿名管道实现父子进程间通信的原理就是,让两个父子进程先看到同一份被打开的文件资源,然后父子进程就可以对该文件进行写入或者读取操作,进而实现父子进程间通信。
注意:
这里父子进程看到的同一份文件资源是由操作系统来维护的,所以父子进程对该文件进行写入操作时,该文件缓冲区当中的数据并不会进行写时拷贝。
管道虽然用的是文件的方案,但操作系统一定不会把进程进行通信的数据刷新到磁盘当中,因为这样做有IO参与会降低效率,而且也没有必要。也就是说,这种文件是一批不会把数据写到磁盘当中的文件,换句话说,磁盘文件和内存文件不一定是一一对应的,有些文件只会在内存当中存在,而不会在磁盘当中存在。
pipe函数
pipe函数用于创建匿名管道,pip函数的函数原型如下:
int pipe(int pipefd[2]); pipe函数的参数是一个传出型参数,数组pipefd用于返回两个指向管道读端和写端的文件描述符:
在创建匿名管道实现父子进程间通信的过程中,需要pipe函数和fork函数搭配使用,具体步骤如下:
父进程调用pipe函数创建管道。
父进程创建子进程。
父进程关闭写端,子进程关闭读端。
注意:
管道只能够进行单向通信,因此当父进程创建完子进程后,需要确认父子进程谁读谁写,然后关闭相应的读写端。
从管道写端写入的数据会被存到内核缓冲,直到从管道的读端读取。
可以站在文件描述符的呃呃角度再来看看这三个步骤:
父进程调用pipe函数创建管道。
父进程创建子进程。
父进程关闭写端,子进程关闭读端。
例如,在以下代码当中,子进程向匿名管道当中写入10行数据,父进程从匿名管道当中将数据读出
#include<stdio.h>
#include<unistd.h>
#include<string.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
int fd[2]={0};
if(pipe(fd)<0)//使用pipe创建匿名管道
{
perror("pipe");
return 1;
}
pid_t id=fork();//使用fork创建子进程
if(id==0)
{
//child
close(fd[0]);//子进程关闭读端
//子进程向管道写入数据
const char*msg="hello father,i am child...";
int count=10;
while(count--)
{
write(fd[1],msg,strlen(msg));
sleep(1);
}
close(fd[1]);//子进程写入完毕,关闭文件
exit(0);
}
close(fd[1]);//父进程关闭写端
//父进程从管道读取数据
char buff[64];
while(1)
{
ssize_t s=read(fd[0],buff,sizeof(buff));
if(s>0)
{
buff[s]='\0';
printf("child send to father%s\n",buff);
}
else if(s==0)
{
printf("read file end\n");
break;
}
else
{
printf("read erro\n");
break;
}
}
close(fd[0]);//父进程读取完毕,关闭文件
waitpid(id,NULL,0);
return 0;
}
管道读写规则
pipe2函数与pipe函数类似,也是用于创建匿名管道,其函数原型如下:
int pipe2(int pipefd[2],int flags) pipe2函数的第二个参数用于设置选项。
当没有数据可读时:
O_NONBLOCK disableread:调用阻塞,即进程暂停执行,一直等到有数据来为止。
O_NONBLOCK enable:read调用返回-1,error值为EAGAIN
当管道满的时候:
O_NONBLOCK disable:read调用阻塞,直到有进程读走数据。
O_NONBLOCK enable:write调用返回-1,errno值为EAGAIN。
3、如果所有管道写端对应的文件描述符被关闭,则read返回0。
4、如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能导致write进程退出。
5、当要写入的数据量不大于PIPE_BUF时,Linux将保证写入的原子性。
6、当要写入的数据量大于PIPE_BUF时,Linux将不再保证写入的原子性。
总结:
读管道:
1.管道中有数据,read返回实际读到的字节数。
2.管道中无数据:
管道写端被全部关闭,read返回0(像读到文件结尾)
写端没有全部被关闭,read阻塞等待(不久的将来可能有数据递达,此时会让出cpu)
写管道:
1.管道读端全部被关闭,进程异常终止(也可使用捕捉SIGPIPE信号,使进程不终止)
2.管道读端没有全部关闭:
1.管道已满, write 阻塞。
2.管道未满,write 将数据写入,并返回实际写入的字节数。
6.3.3命名管道
命名管道的原理
匿名管道只能用于具有共同祖先的进程(具有亲缘关系的进程)之间的通信,通常,一个管道由一个进程创建,然后该进程调用fork,此后父子进程之间就可应用该管道。如果要实现两个毫不相关进程之间的通信,可以使用命名管道来做到。命名管道就是一种特殊类型的文件,两个进程通过命名管道的文件名打开同一个管道文件,此时这两个进程也就看到了同一份资源,进而就可以进行通信了。
使用命令创建命名管道
mkfifo fifo
创建出来的文件类型是p,代表该文件是命名管道文件,使用这个命名管道就像使用普通文件一样,就能实现两个进程之间的通信了。
创建一个命名管道
int mkfifo(const char* pathname,mode_t mode); mkfio函数的第一个参数是pathname,表示要创建的命名管道。
若pathname以路径的方式给出,则将命名管道文件创建在pathname路径下。
若pathname以文件名的方式给出,则将命名管道文件默认创建在当前路径下。(注意当前路径的含义)
mkfifo函数的第二个参数是mode,表示创建命名管道文件的默认权限。
umask函数将文件默认掩码设置为0。
umask(0); mkfifo函数的返回值。
命名管道创建成功,返回0。
命名管道创建失败,返回-1。
#include <sys/types.h> #include <sys/stat.h> #include <stdio.h> #define FILE_NAME "myfifo" int main() { umask(0); //将文件默认掩码设置为0 if (mkfifo(FILE_NAME, 0666) < 0) { // 使用mkfifo创建命名管道文件 perror("mkfifo"); return 1; } //create success... return 0; }
用命名管道实现server&client通信
实现服务端(server)和客户端(client)之间的通信之前,我们需要先让服务端运行起来,需要让服务端运行后创建一个命名管道文件,然后再以读的方式打开该命名管道文件,之后服务端就可以从该命名管道当中读取客户端发来的通信信息了。
server
#include "comm.h" int main() { umask(0);//将文件默认掩码设置为0 if (mkfifo(FILE_NAME,0666) < 0) { //使用mkfifo创建命名管道文件 perror("mkfifo"); return 1; } int fd = open(FILE_NAME, O_RDONLY); //以读的方式打开命名管道文件if(fd < 0) { if(fd<0) { perror("open"); return 2; } char msg[128]; while (1) { msg[0] = '\0';//每次读之前将msg清空 //从命名管道当中读取信息 ssize_t s = read(fd, msg, sizeof(msg) - 1); if (s > 0) { msg[s] = '\0'; // 手动设置0',便于输出 printf("client# %s\n", msg); //输出客户端发来的信息 } else if (s == 0) { printf("client quit!\n"); break; } else { printf("read error!\n"); break; } } close(fd); //通信完毕,关闭命名管道文件 return 0; }
命名管道和匿名管道的区别
匿名管道由pipe函数创建并打开。
命名管道由mkfifo函数创建,由open函数打开。
FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在于它们创建与打开的方式不同,一旦这些工作完成之后,它们具有相同的语义。
6.4mmap
存储映射I/O使一个磁盘文件与存储空间中的一个缓冲区相映射。于是当从缓冲区中取数据,就相当于读文件中的相应字节。于此类推,将数据存入缓冲区,则相应的字节就自动写入文件。这样,就可使用地址(指针)完成I/O操作,对文件的操作就可以改对内存的操作。
使用这种方法,首先应通知内核,将一个指定文件映射到存储域中。这个映射工作可以通过mmap函数来实现。
mmap将一个文件或者其它对象映射进内存。文件被映射到多个页上,如果文件的大小不是所有页的大小之和,最后一页不被使用的空间将会清零。
函数原型:
<sys/mman.h> void *mmap(void *addr,size_t length,int prot,int flags,int fd,off_t offset); int munmap(void *addr,size_t length);
6.4.1内存映射的步骤
用open系统调用打开文件,并返回文件描述符fd;
用mmap建立内存映射,并返回映射首地址指针addr;
对映射(文件)进行各种操作,显示,修改
用munmap(void *addr,size_t lenght)关闭内存映射
用close系统调用关闭文件fd
6.4.2主要功能
该函数主要用途有三个:
将一个普通文件映射到内存中,通常在需要对文件进行频繁读写时使用,这样用内存读写取代I/O读写,以获得较高的性能
将特殊文件进行匿名内存映射,可以为关联进程提供共享内存空间
为无关联的进程提供共享内存空间,一般也是将一个普通文件映射到内存中
6.4.3参数及返回值
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset); 参数:
参数addr:指向欲映射的内存起始地址,通常设为NULL,代表让系统自动选定地址,映射成功后返回该地址。
参数length映射区的长度。
参数prot:映射区域的保护方式。可以为以下几种方式的组合:
PROT_READ映射区域可被读取
PROT_WRITE映射区域可被写入
参数flags:影响映射区域的各种特性。在调用mmap()时必须要指定MAP_SHARED或MAP_PRIVATE。
MAP_SHARED对映射区域的写入数据会复制回文件内,而且允许其他映射该文件的进程共享。
MAP_PRIVATE对映射区域的写入操作会产生一个映射文件的复制,即私人的“写入时复制”(copy on write)对此区域作的任何修改都不会写回原来的文件内容。
MAP_ANONYMOUS建立匿名映射。此时会忽略参数fd,不涉及文件,而且映射区域无法和其他进程共享。
参数fd:要映射到内存中的文件描述符。如果使用匿名内存映射时,即flags中设置了MAP_ANONYMOUS,fd设为-1。有些系统不支持匿名内存映射,则可以使用fopen打开/dev/zero文件,然后对该文件进行映射,可以同样达到匿名内存映射的效果。
参数offset:文件映射的偏移量,通常设置为0,代表从文件最前方开始对应,offset必须是分页大小的整数倍。
返回值:
若映射成功则返回映射区的内存起始地址,否则返回MAP_FAILED(-1),错误原因存于errno中。
6.4.4系统掉用mmap()用于共享内存的两种方式
使用普通文件提供的内存映射:
适用于任何进程之间。此时,需要打开或创建一个文件,然后再调用mmap ()典型调用代码如下:
fd=open(name, flag, mode); if(fd<0) ... ptr=mmap(NULL, len , PROT_READ|PROT_WRITE, MAP_SHARED , fd , 0); 使用特殊文件提供匿名内存映射:
适用于具有亲缘关系的进程之间。由于父子进程特殊的亲缘关系,在父进程中先调mmap(),然后调用fork()。那么在调用fork()之后,子进程继承父进程匿名映射后的地址空间,同样也继承mmap()返回的地址,这样,父子进程就可以通过映射区域进行通信了。注意,这里不是一般的继承关系。一般来说,子进程单独维护从父进程继承下来的一些变量。而mmap()返回的地址,却由父子进程共同维护。对于具有亲缘关系的进程实现共享内存最好的方式应该是采用匿名内存映射的方式。此时,不必指定具体的文件,只要设置相应的标志即可。
6.4.5mmap注意事项
1.创建映射区的过程中,隐含着一次对映射文件的读操作。
2.当MAP_SHARED时,要求:映射区的权限应<=文件打开的权限(出于对映射区的保护)。而MAP_PRIVATE则无所谓,因为mmap中的权限是对内存的限制。
3.映射区的释放与文件关闭无关。只要映射建立成功,文件可以立即关闭。
4.特别注意,当映射文件大小为0时,不能创建映射区。所以:用于映射的文件必须要有实际大小! ! mmap使用时常常会出现总线错误,通常是由于共享文件存储空间大小引起的。如,400字节大小的文件,在建立映射区时offset 4096字节,则会报出总线错。
- munmap传入的地址一定是mmap的返回地址。坚决杜绝指针++操作。
6.如果文件偏移量必须为4K的整数倍
7.mmap创建映射区出错概率非常高,一定要检查返回值,确保映射区建立成功再进行后续操作。
6.5消息队列
6.5.1消息队列概念
消息队列可以看作一个消息链表,有足够写权限的进程可往队列中放置消息,有足够读权限的进程可从队列中取走消息。每个消息都是一个记录,它由发送者赋予一个优先级。在某个进程往一个队列写入消息之前,并不需要另外某个进程在该队列上等待消息的到达(恰好与PIPE和 FIFO相反,因为管道中除非读出者已存在,否则先有写入者是没有意义的)。
6.5.2消息队列的创建和关闭
POSIX消息队列的创建,关闭和删除用的以下三个函数接口:
#include <mqueue.h> mqd_t mq_open(const char name, int oflag, / mode_t mode,struct mq_attr *attr */); //成功返回消息队列描述符,失败返回 -1 mqd_t mq_close(mqd_t mqdes); mqd_t mq_unlink(const char *name); mq_open函数
mq_open函数用于创建一个新的消息队列或打开一个已存在的消息列表。
mq_close函数
mq_close用于关闭一个消息队列,和文件的close类型,关闭后,消息队列并不从系统中删除。一个进程结束,会自动调用关闭打开的消息队列。
6.5.3消息队列的属性
struct mq_attr
{
long mq_flags;/* Message queue flags(0,O_NONBLOCK)*/
long mq_maxmsg; /* Maximum number of messages.*/最大消息数
long mq_msgsize;/* Maximum message size.*/最大消息大小
long mq_curmsgs;/*Number of messages currently queued.*/当前消息的个数
};
6.5.4POSIX消息队列的使用
6.5.5消息队列限制
POSIX消息队列本身的限制就是mq_attr中的mq_maxmsg和mq_msgsize,分别用于限定消息队列中的最大消息数和每个消息的最大字节数。在前面已经说过了,这两个参数可以在调用mq_open创建一个消息队列的时候设定。当这个设定是受到系统内核限制的。
第七章 内存管理
7.1操作系统存储层次
常见的计算机存储层次如下:
寄存器:CPU提供的,读写ns级别,容量字节级别
主存:动态内存,读写100ns级别,容量GB级别
外部存储介质:磁盘、SSD,读写ms级别,容量可扩展到TB级别。
这里忽略CPU的缓存、主存的磁盘以及磁盘的缓存
7.2什么是内存
简单地说,内存就是一个数据货架。内存是一个最小的存储单位,大多数都是一个字节。内存用内存地址来为每个字节的数据顺序编号。因此,内存地址说明了数据在内存中的位置。内存地址从0开始,每次增加1。这种线性增加的存储器地址称为线性地址(linear address)。为了方便,我们用十六进制数来表示内存地址,比如0x00000003、0x1A010CB0。这里“0x"用来表示十六进制。“0x"后面跟着的,就是作为内存地址的十六进制数。
内存地址的编号有上限。地址空间的范围和地址总线(address bus)的位数直接相关。CPU通过地址总线来向内存说明想要存取数据的地址。以英特尔32位的80386型CPU为例,这款CPU有32个针脚可以传输地址信息。每个针脚对应了一位。如果针脚上是高电压,那么这一位是1。如果是低电压,那么这一位是0。32位的电压高低信息通过地址总线传到内存的32个针脚,内存就能把电压高低信息转换成32位的二进制数,从而知道CPU想要的是哪个位置的数据。用十六进制表示,32位地址空间就是从0x00000000到0xFFFFFFFF。
内存的存储单元采用了随机读取存储器(RAM,Random Access Memory)。所谓的“随机读取”,是指存储器的读取时间和数据所在位置无关。与之相对,很多存储器的读取时间和数据所在位置有关。就拿磁带来说,我们想听其中的一首歌,必须转动带子。如果那首歌是第一首,那么立即就可以播放。如果那首歌恰巧是最后一首,我们快讲到可以播放的位置就需要花很长时间。我们已经知道,进程需要调用内存中不同位置的数据。如果数据读取时间和位置相关的话,计算机就很难把控进程的运行时间。因此,随机读取的特性是内存成为主存储器的关键因素。
内存提供的存储空间,除了能满足内核的运行需求,还通常能支持运行中的进程。即使进程所需空间超过内存空间,内存空间也可以通过少量拓展来弥补。换句话说,内存的存储能力,和计算机运行状态的数据总量相当。内存的缺点是不能持久地保存数据。一旦断电,内存中的数据就会消失。因此,计算机即使有了内存这样一个主存储器,还是需要硬盘这样的外部存储器来提供持久的储存空间。
7.3早期的内存分配机制
在早期的计算机中,要运行一个程序,需要把程序全部加载到物理内存(可以理解为内存条上的内存,所有的程序运行都是在内存中运行,CPU运行程序时,如果要访问外部存储诸如磁盘,那么必须先把磁盘内存拷贝到内存中CPU才能操作,内存是CPU和外部存储的桥梁)如果我们的一个计算机只运行一个程序,那么只有这个程序所需要的内存空间不超过物理内存空间的大小,就不会有问题,计算机如何把有限的物理内存分配给多个程序使用呢?
某台计算机总的物理内存大小是128M,现在同时运行两个程序A和B,A需占用内存10M,B需占用内存110M。计算机在给程序分配内存时会采取这样的方法:先将内存中的前10M分配给程序A,接着再从内存中剩余的118M中划分出110M分配给程序B。这种分配方法可以保证程序A和程序B都能运行,但是这种简单的内存分配策略问题很多。
7.4虚拟内存
内存的一项主要任务,就是存储进程的相关数据。之前已经看到过进程空间的程序段、全局数据、堆和栈,以及这些存储结构在进程运行中所起的关键作用。有趣的是,尽管不能直接读写内存中地址的关系如此紧密,但进程并不能直接访问内存。在Linux下,进程不能直接读写内存中地址为0x1位置的数据。进程中能访问的地址,只能是虚拟内存地址(virtual memory address)。操作系统会把虚拟内存地址翻译成真实的内存地址。这种内存管理方式,称为虚拟内存(virtual memory)。
每个进程都有自己的一套虚拟内存地址,用来给自己的进程空间编号。进程空间的数据同样以字节为单位,依次增加。从功能上说,虚拟内存地址和物理内存地址类似,都是为数据提供位置索引。进程的虚拟内存地址相互独立。因此,两个进程空间可以有相同的虚拟内存地址,如0x10001000。虚拟内存地址和物理内存地址又有一定的对应关系,如图1所示。对进程某个虚拟内存地址的操作,会被CPU翻译成对某个具体内存地址的操作。
图1 虚拟内存地址和物理内存地址的对应
应用程序来说对物理内存地址一无所知。它只可能通过虚拟内存地址来进行数据读写。程序中表达的内存地址,也都是虚拟内存地址。进程对虚拟内存地址的操作,会被操作系统翻译成对某个物理内存地址的操作。由于翻译的过程由操作系统全权负责,所以应用程序可以在全过程中对物理内存地址一无所知。因此,C程序中表达的内存地址,都是虚拟内存地址。比如在C语言中,可以用下面指令来打印变量地址:
int v =0;
printf( "%p", (void* ) &v);
本质上说,虚拟内存地址剥夺了应用程序自由访问物理内存地址的权利。进程对物理内存的访问,必须经过操作系统的审查。因此,掌握着内存对应关系的操作系统,也掌握了应用程序访问内存的闸门。借助虚拟内存地址,操作系统可以保障进程空间的独立性。只要操作系统把两个进程的进程空间对应到不同的内存区域,就让两个进程空间成为“老死不相往来"的两个小王国。两个进程就不可能相互篡改对方的数据,进程出错的可能性就大为减少。
另一方面,有了虚拟内存地址,内存共享也变得简单。操作系统可以把同一物理内存区域对应到多个进程空间。这样,不需要任何的数据复制,多个进程就可以看到相同的数据。内核和共享库的映射,就是通过这种方式进行的。每个进程空间中,最初一部分的虚拟内存地址,都对应到物理内存中预留给内核的空间。这样,所有的进程就可以共享同一套内核数据。共享库的情况也是类似。对于任何一个共享库,计算机只需要往物理内存中加载一次,就可以通过操纵对应关系,来让多个进程共同使用。IPC中的共享内存,也有赖于虚拟内存地址。
虚拟化的出现和硬件有密不可分的联系,可以说是软硬件组合的结果,虚拟地址空间就是在程序和物理空间所增加的中间层,这也是内存管理的重点。
7.5内存分页 虚拟内存地址和物理内存地址的分离,给进程带来便利性格安全性。但虚拟内存地址和物理内存地址的翻译,又会额外耗费计算机资源。在多任务的现代计算机中,虚拟内存地址已经成为必备的设计。那么,操作系统必须要考虑清楚,如何能高效地翻译虚拟内存地址。
记录对应关系最简单的办法,就是把对应关系记录在一张表中。为了让翻译速度足够地快,这个表必须加载在内存中。不过,这种记录方式惊人地浪费。如果树莓派1GB物理内存的每个字节都有一个对应记录的话,那么光是对应关系就要远远超过内存的空间。由于对应关系的条目众多,搜索到一个对应关系所需的时间也很长。这样的话,会让树莓派陷入瘫痪。
因此,Linux采用了分页(paging)的方式来记录对应关系。所谓的分页,就是以更大尺寸的单位页(page)来管理内存。在Linux中,通常每页大小为4KB。如果想要获取当前系统的的内存页大小,可以使用命令:
$getconf PAGE_SIZE
得到结果,即内存分页的字节数:
4096
返回的4096代表每个内存页可以存放4096个字节,即4KB。Linux把物理内存和进程空间都分割成页。
内存分页,可以极大地减少所要记录的内存对应关系。我们已经看到,以字节为单位的对应记录实在太多。如果把物理内存和进程空间的地址都分成页,内核只需要记录页的对应关系,相关的工作量就会大为减少。由于每页的大小是每个字节的4000倍。因此,内存中的总页数只是总字节数的四千分之一。对应关系也缩减为原始策略的四千分之一。分页让虚拟内存地址的设计有了实现的可能。
无论是虚拟页,还是物理页,一页之内的地址都是连续的。这样的话,一个虚拟页和一个物理页对应起来,页内的数据就可以按顺序——对应。这意味着,虚拟内存地址和物理内存地址的末尾部分应该完全相同。大多数情况下,每一页有4096个字节。由于4096是2的12次方,所以地址最后12位的对应关系天然成立。我们把地址的这一部分称为偏移量(offset)。偏移量实际上表达了该字节在页内的位置。地址的前一部分则是页编号。操作系统只需要记录页编号的对应关系。
7.6多级分页表
内存分页制度的关键,在于管理进程空间页和物理页的对应关系。操作系统把对应关系记录在分页表(page table)中。这种对应关系让上层的抽象内存和下层的物理内存分离,从而让Linux能灵活地进行内存管理。由于每个进程会有一套虚拟内存地址,那么每个进程都会有一个分页表。为了保证查询速度,分页表也会保存在内存中。分页表有很多种实现方式,最简单的一种分页表就是把所有的对应关系记录到同一个线性列表中,即如图2中的“对应关系”部分所示。
这种单一的连续分页表,需要给每一个虚拟页预留一条记录的位置。但对于任何一个应用进程,其进程空间真正用到的地址都相当有限。我们还记得,进程空间会有栈和堆。进程空间为栈和堆的增长预留了地址,但栈和堆很少会占满进程空间。这意味着,如果使用连续分页表,很多条目都没有真正用到。因此,Linux中的分页表,采用了多层的数据结构。多层的分页表能够减少所需的空间。我们来看一个简化的分页设计,用以说明Linux的多层分页表。我们把地址分为了页编号和偏移量两部分,用单层的分页表记录页编号部分的对应关系。对于多层分页表来说,会进一步分割页编号为两个或更多的部分,然后用两层或更多层的分页表来记录其对应关系,如图3所示。
在图3的例子中,页编号分成了两级。第一级对应了前8位页编号,用2个十六进制数字表示
7.7页表项
7.9常见的页面置换算法
在地址映射过程中,如果在页面
最优算法在当前页面中置换最后要访问的页面。不
第八章 信号
8.1信号的概念
信号在我们的生活中随处可见,如:古代战争中摔杯为号;现代战争中的信号弹;体育比赛中使用的信号枪他们都有共性:
简单
不能携带大量信息
满足某个特设条件才发送
信号是信息的载体,Linux/UNIX环境下,古老、经典的通信方式,现下依然是主要的通信手段。
UNIX早期版本就提供了信号机制,但不可靠,信号可能丢失。Berkeley和AT&T都对信号模型做了更改,增加了可靠的机制。但彼此不兼容。POSIX.1对可靠信号历程进行了标椎化。
8.2信号的机制
A给B发送信号,B收到信号之前执行自己的代码,收到信号后,不管执行到程序的什么位置,都要暂停运行,去处理信号,,处理完毕再继续执行。与硬件中断类似---异步模式。但信号时软件层面上实现的中断,早期常被称为“软中断”。
信号的特质:由于信号是通过软件方法实现,其实现手段导致信号有很强的延时性但对于用户来说,这个延迟时间非常短,不易察觉。
每个进程收到的所有信号,都是由内核负责发送的内核处理。
8.3与信号相关的事件和状态
产生信号:
按键产生,如:Ctrl+c、Ctrl+z、Ctrl+\
系统调用产生,如:kill、raise、about
软件条件产生,如:定时器(闹钟)alarm
硬件异常产生,如:非法访问内存(段错误)、除0(浮点数例外)、内存对齐出错(总线错误)
命令产生,如:kill命令
递达:递送并且到达进程
未决:产生递达之间的状态。主要由于阻塞(屏蔽)导致该状态。
8.4信号的处理方式
执行默认动作
忽略(丢弃)
捕捉(调用户处理函数)
Linux内核的进程控制块PCB时一个结构体,task_struct,除了包含进程id,状态,工作目录,用户id,组id,文件描述符,还包含了信号相关的信息,主要指阻塞信号集和未决信号集。
阻塞信号集(信号屏蔽字):
将某些信号加入集合,对他们设置屏蔽,当屏蔽x信号后,再收到该信号,该信号的处理将推后(解除屏蔽后);
未决信号集:
信号产生,未决信号集中描述该信号的位立刻翻转为1,表示信号处于未决状态。当该信号被处理,对应位翻转回为0。这一刻往往非常短暂。
信号产生后由于某些原因(主要是阻塞)不能抵达。这类信号的集合称之为未决信号集。在屏蔽解除前,信号一直处于未决状态。
8.5信号的编号
可以使用kill-I命令查看当前系统可使用的信号用哪些。
-
SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
-
SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
-
SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
-
SIGSTKFLT 17) SIGCHLD 18) SIGCONT 1 9) SIGSTOP 20) SIGTSTP
-
SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
-
SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
-
SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
-
SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
-
SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
-
SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
-
SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7
-
SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2
-
SIGRTMAX-1 64) SIGRTMAX
不存在编号为0的信号。其中1-31号信号称之为常规信号(也叫普通信号或标准信号),34-36称之为实时信号,驱动编程与硬件相关。名字区别不大。而前32个名字各不相同。
8.6信号4要素
与变量三要素类似的,每个信号也有其必备4要素,分别是:
编号 2.名称 3默认处理动作 4.事件
可通过man 7 signal查看帮助文档获取。
默认动作:
Term:终止进程
Ign:忽略信号(默认)
Core:终止信号,生成Core文件。(查验进程死亡原因,用于gdb调试)
Stop:停止(暂停)进程
Cont:继续运行进程
注意从man 7 signal帮助文档中查看到:The signal SIGKILLand SIGSTOP cannot be caught,blocked,or ignored.
这里特别强调了9)SIGKLL和19)SIGSTOP信号,不允许忽略和捕捉,只能执行默认动作。甚至不能将其设置为阻塞。
另外需清楚,只有每个信号所对应的事件发生了,该信号才会被递送(但不一定递达),不应乱发信号。
8.7Linux常规信号一览表
SIGHUP:
8.8信号的产生
8.8.1终端按键产生信号
8.8.2硬件异常产生信号
8.8.3kill函数/命令产生信号
kill命令产生信号:kill-SIGKILL pid
kill函数:给指定进程发送指定信号(不一定杀死)
8.8.4软件条件产生信号
alarm函数
设置定时器(闹钟)。在指定seconds后,内核会给当前进程发送14)SIGALRM信号。进程收到该信号,默认动作终止。
每个进程都有且只有唯一个定时器。
unsigned int alarm(unsigned int seconds);返回0或剩余的秒数,无失败。
常用:取消定时器alarm(0),返回旧时钟余下秒数。
例:alarm(5)—>3se—>calarm(4)—>5sec—>alarm(5)—>alarm(0)
定时,与进程状态无关(自然定时法)!就绪、运行、挂起(阻塞、暂停)、终止、僵尸···无论进程处于何种状态,alarm都计时。
使用time命令查看程序执行的时间。程序运行的瓶颈在于IO,优化程序,首选优化IO。
实际执行时间=系统时间+用户时间+等待时间
setitimer函数
设置定时器(闹钟)。可代替alarm函数。精度微秒us,可以实现周期定时。
8.9信号集操作函数
内核通过读取未决信号集来判断信号是否应被处理。信号屏蔽字mask可以影响未决信号集。而我们可以在应用程序中自定义set来改变mask。已达到屏蔽指定信号的目的。
8.9.1信号集设定
sigset_t set;//typedef usigned long sigset_t;
int sigemptyset(sigset_t *set);
功能:蒋某个信号集清零
成功:0;失败:-1
int sigfillset(sigset_t,*set)
功能:蒋某个信号集置1
成功:0;失败:-1
int sigaddset(sigset_t*set,intsignum);
功能:将某个信号加入信号集
成功:0;失败:-1
int sigdelset(sigset_t*set,int signum);
功能:将某个信号清除信号集
成功:0;失败:-1
intsigismember(const sigset_t*set,intsignum);
判断某个信号是否在信号集中
在:1;不在:0;出错:-1;
sigset_t类型的本质是位图。但不应该直接使用位操作,而应该使用上述函数,保证跨系统操作有效。
sigprocmask函数
用来屏蔽信号、解除屏蔽也使用该函数。其本质,读取或修改进程的信号屏蔽字(PCB中)
严格注意,屏蔽信号:只是将信号处理延后执行(延至解除屏蔽);而忽略表示将信号做丢弃处理。
int sigprocmask(int how,const sigset_tset,sigset_toldset);
成功:0;失败:-1,设置errno
参数:
set:传入参数,是一个位图,set中哪位置1,就表示当前进程屏蔽哪个信号。
oldset:传出参数,保存旧的信号屏蔽集。
how参数取值:假设当前的信号屏蔽字为mask
SIG_BLOCK:当how设置为此值,set表示需要屏蔽的信号。相当于mask=mask|set
SIG_UNBLOCK:当how设置为此值,set表示需要解除屏蔽的信号。相当于mask=mask & ~set
SIG_SETMASK:当how设置为此值,set表示用于替代原始屏蔽集的新屏蔽集。相当于mask=set,若调用signprocmask解除了对当前若干个信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
sigpending函数
读取当前进程的未决信号集
int sigpending(sigset_t*set);
set传出参数。
返回值:成功:0;失败:-1,设置errno
8.10信号捕捉
signal函数
注册一个信号捕捉函数:
typedef void(*sighandler_t)(int);
sighandler_t signal(int signmu,sighandler_t handler);
该函数由ANSI定义,由于历史原因在不同版本的UNIX和不同版本的Linux中可能有不同的行为。因此应该尽量避免使用它,取而代之使用sigaction函数。
sigaction函数
修改信号处理动作(通常在Linux用来注册一个信号的捕捉函数)
int sigaction(int signum,const strutsigactionact,struct sigaction * oldact);
成功:0;失败:-1,设置errno
参数:
act:传入参数,新的处理方式。
oldact:传出参数,旧的处理方式。
第九章 线程
9.1线程概念
LWP:light weight process 轻量级的进程,本质仍是进程(在Linux环境下)
进程:独立地址空间,拥有PCB
线程:也有PCB,但没有独立的地址空间(共享)
区别:在于是否共享地址空间。独居(进程);合租(线程)。
Linux下:线程:最小的执行单位,调度的基本单位。
进程:最小分配资源单位,可看成时只有一个线程的进程。
9.2线程控制原语
9.2.1pthread_self函数
功能:获取线程ID其作用对应进程中个getpid()函数。
pthread_t pthread_self(void);返回值:成功:0;失败:无!
线程ID:pthread_t类型,本质:在Linux下为无符号整数(%lu),其它系统中可能是结构体实现
线程ID是进程内部,识别标志。(两个进程间,线程ID允许相同)
注意:不应该使用全局变量pthread_t tid,在子线程中通过pthread_create传出参数来获取线程ID,而应使用pthread_self。
9.2.2ptherad_create函数
功能:创建一个新线程。其作用,对应进程中fork()函数。
int pthread_create(ptherad_t thread,const pthread_attr_attr,void*(start_routine)(void),void *arg);
返回值:成功:0;失败:错误号----Linux环境下,所用线程特点,失败均直接返回错误号。
参数:
pthread_t:当前Linux中可理解为:typedef unsigned long int pthread_t;
参数1:传出参数,保存系统为我们分配好的线程ID
参数2:通常传NULL,表示使用线程默认属性。若想使用具体属性也可以修改该参数。
参数3:函数指针,指向线程主函数(线程体),该函数运行结束,则线程结束。
参数4:线程主函数执行期间所使用的参数,如要传多个参数,可以使用结构体封装
9.2.3pthread_exit函数
功能:将单个线程退出
void pthread_exit(void *retval);参数:retval表示线程退出状态,通常传NULL
使用exit将指定线程退出,可以吗?
结论:线程中禁止使用exit函数,会导致进程内所有线程全部退出。
在不添加sleep控制输出顺序的情况下。pthread_create在循环中,几乎瞬间创建5个线程,但只有第1个线程有机会输出(或者第2个也有,也可能没有,取决于内核调度)如果第三个线程执行了exit,将整个进程退出了,所以全部线程退出了。
所以,多线程环境中应尽量少用,或者不适用exit函数,取而代之使用pthread_exit函数,将单个线程退出。任何线程里exit导致进程退出
9.2.4pthread_join函数
功能:回收线程
int pthread_join(pthread_t thread,void **retval);
成功:0;失败:错误号
参数:thread线程ID;
retval:储存线程结束状态。
第十章 线程同步
进程的重点是进程间通信,线程是线程同步。fork()创建子进程之后,子进程有自己的独立地址空间和PCB,想和父进程或其它进程通信,就需要各种通信方式,例如管道(无名管道)、有名管道(命名管道)、信号、消息队列、共享内存等;而pthread_create创建子线程之后,子线程没有独立的地址空间,大部分数据都是共享的,如果同时访问数据,就会造成混乱,所以要控制,就是线程同步了。
10.1什么时线程同步
同步就是协同步调,按预定的先后次序进行运行。如:你说完,我再说。这里的同步不是同时进行。应是指协同、协助、互相配合。线程同步是指多线程通过特定的设置(如互斥量,条件变量等)来控制线程之间的执行顺序(即所谓的同步)也可以说是线程之间通过同步建立起执行顺序的关系,如果没有同步,那线程之间是各自运行各自的!
线程互斥是指对于共享的进程系统资源,在各单个线程访问时的排它性。当有若干个线程都要使用某一共享资源时,任何时刻最多只允许一个线程去使用,其它要使用该资源的线程必须等待,直到占用资源者释放该资源。
10.2线程同步的方式
10.2.1互斥锁(互斥量)
介绍
Linux中提供了一把互斥锁mutex(也称之为互斥量)。
每个线程在对资源操作前都尝试先加锁,成功加锁才能操作,操作结束解锁。
资源还是共享的,线程间也还是竞争的,但通过“锁”就将资源的访问变成互斥操作,而后与时间有关的错误也不会再产生了。
(2)相关函数
主要应用函数:
pthread_mutex_init 函数
pthread_mutex_destory 函数
pthread_mutex_lock 函数
pthread_mutex_trylock 函数
pthread_mutex_unlock 函数
函数解释
1、
int pthread_mutex_init(pthread_mutex_t *mutex,const pthread_mutexattr_t *mutexattr) 功能:初始化一个互斥锁(互斥量)
参1:传出参数,调用时应传&mutex
参2: 互斥量属性。是一个传入参数,通常传NULL,选用默认属性(线程间共享)。
2、
int pthread_mutex_lock(pthread_mutex_t *mutex); 功能:加锁
如果加锁不成功,线程阻塞,阻塞到持有互斥量的其它线程解锁为止。
3、
int pthread_mutex_trylock(pthread_mutex_t *mutex); 功能:尝试加锁
trylock 加锁失败直接返回错误号(如:EBUSY) ,不阻塞。
4、
int pthread_mutex_unlock(pthread_mutex_t *mutex); 功能:解锁
unlock主动解锁函数,同时将阻塞在该锁上的所有线程全部唤醒,至于哪个线程先被唤醒,取决于优先级、调度。默认:先阻塞、先唤醒。
5、
int pthread_mutex_destroy(pthread_mutex_t *mutex); 功能:销毁一个互斥锁
以上5个函数的返回值都是:成功返回 0,失败返回错误号。
pthread_mutex_t类型,其本质是一个结构体。为简化理解,应用时可忽略其实现细节,简单当成整数看待。
pthread_mutex_t mutex;变量mutex只有两种取值1、0。
在访问共享资源前加锁,访问结束后立即解锁。锁的“粒度”应越小越好。
10.2.2读写锁
与互斥量类似,但读写锁允许更高的并行性。其特性为:写独占,读共享(读锁优先级高于写锁)。
特别强调:读写锁只有一把,但其具备两种状态:
读模式下加锁状态(读锁)
写模式下加锁状态(写锁)
读写锁特性:
读写锁是“写模式加锁”时,解锁前,所有对该锁加锁的线程都会被阻塞。
读写锁是“读模式加锁”时,如果线程以读模式对其加锁会成功;如果线程以写模式加锁会阻塞。
3、如果一个读线程和一个写线程同时申请读写锁,读线程优先加锁。
读写锁也叫共享-独占锁。当读写锁以读模式锁住时,它是以共享模式锁住的;当它以写模式锁住时,它是以独占模式锁住的。写独占、读共亭。
读写锁非常适合于对数据结构读的次数远大于写的情况。
读写锁的场景分析
一:均是读要求
当下面4个线程请求读锁时,不管是否是同时,都能成功上锁访问数据。
二:均是写要求
当下面四个线程同时请求写锁时,因为写独占,所以只能依次加锁,谁先抢到谁先加锁,没抢到的阻塞等待写锁被释放再继续加锁。
三:线程持有写锁期间,读线程和写线程同时申请加锁
在开始的时侯p1持有写锁并持续持有5秒,在第1秒时p2线程申请写锁,因为写独占,p2会阻塞等待,在第2秒时p3线程申请读锁,也因为写独占p3线程也无法加锁成功导致在线程持有写锁期间,读线程和写线程同时申请加锁,p2,p3线程会一直阻塞直到p1线程解锁,在ubuntu20中,读锁优先级高于写锁,导致在p1解锁时,p3线程会加锁成功。
四:某线程持有读锁期间,读线程和写线程同时申请加锁
在开始时p1请求读锁并持续持有读锁5秒钟,在第1秒时p2请求写锁会被阻塞,在第2秒时p3请求读锁,这个时候相当于在p1持有读锁时,p2, p3都想加锁且分别为请求写锁和读锁,在ubuntu20中,读锁优先级高于写锁,所以p3会加锁成功,p2需要等到p3解锁后才能加锁成功,如果在p3持有读锁期间还有其他线程持续请求读锁也会加锁成功,可能会导致p2一直无法请求写锁成功,造成饥饿。
相关函数
主要应用函数
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t * restrict attr); int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock); int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock); int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock); int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock); int pthread_rwlock_unlock(pthread_rwlock_t *rwlock); int pthread_rwlock_destroy(pthread_rwlock_t *rwlock); 10.2.3条件变量 条件变量本身不是锁!但它也可以造成线程阻塞。通常与互斥锁配合使用。给多线程提供一个会合的场所。
相关函数
函数解析
1、
int pthread_cond_init(pthread_cond_t *cond, pthread_condattr_t *cond_attr); 功能:初始化一个条件变量
参1:要初始化的条件变量
参2: attr表条件变量属性,通常为默认值,传NULL即可
2、
int pthread_cond_destroy(pthread_cond_t *cond); 功能:销毁一个条件变量
3、
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex); 功能:阻塞等待一个条件变量函数作用:
1、阻塞等待条件变量cond(参1)满足
2、释放已掌握的互斥锁(解锁互斥量)相当于pthread_mutex_unlock(&mutex);1、2、两步为一个原子操作。
3、当被唤醒,pthread_cond_wait函数返回时,解除阻塞并重新申请获取互斥锁pthread_mutex_lock (&mutex) ;
4、
int pthread_cond_timedwait(pthread_cond_t *cond,pthread_mutex_t *mutex, const struuct 功能:限时等待一个条件变量
参3:查看struct timespec结构体。
struct timespec
{
time_t tv_sec; /* seconds*/秒
long tv_nsec; /* nanosecondes*/纳秒
}
形参abstime:绝对时间。
如: time(NULL)返回的就是绝对时间。而alarm(1)是相对时间,相对当前时间定时1秒钟。
struct timespec t = {1,0};
pthread_cond_timedwait (&cond, &mutex,&t);只能定时到1970 年1月1日00:00:01秒(早已经过去)
正确用法:
time_t cur = time (NULL);获取当前时间。
struct timespec t;定义 timespec结构体变量t
t. tv_sec = cur+1;定时1秒
5、
int pthread_cond_signal(pthread_cond_t *cond);
功能:唤醒一个阻塞在条件变量上的线程
6、
int pthread_cond_broadcast(pthread_cond_t *cond);
功能:唤醒全部阻塞在条件变量上的线程
10.3生产者消费者模型
线程同步典型的案例即为生产者消费者模型,而借助条件变量来实现这一模型,是比较常见的一种方法。假定有两个线程,一个模拟生产者行为,一个模拟消费者行为。两个线程同时操作一个共享资源(一般称之为汇聚),生产者向其中添加产品,消费者从中消费掉产品。
条件变量的优点:
相较于mutex 而言,条件变量可以减少竟争。
如直接使用mutex,除了生产者、消费者之间要竞争互斥量以外,消费者之间也需要竞争互斥量,但如果汇聚(链表)中没有数据,消费者之间竞争互斥锁是无意义的。有了条件变量机制以后,只有生产者完成生产,才会引起消费者之间的竞争。提高了程序效率。
10.4信号量
信号量是用来解决线程间同步或互斥的一种机制,也是一个特殊的变量,变量的值代表着当前可以利用的资源数。
如果等于0,那就意味着现在没有资源可用。
根据信号量的值可以将信号量分为二值信号量和计数信号量:
(计数信号量)就像一间公共厕所,里面一共有十个坑(最大是32767),算是十个资源。在同一时间可以容纳十个人,当满员的时候,外面的人必须等待里面的人出来,释放一个资源,然后才能再进一个,当他进去之后,厕所又满员了,外面的人还得继续等待……
(二值信号量)就像自己家的卫生间,一般只有一个马桶,在同一时间只能有一个人来用。
信号量只能进行两个原子操作,P操作和V操作,概念:
原子操作,就是不能被更高等级中断抢夺优先的操作。
由于操作系统大部分时间处于开中断状态,所以,一个程序在执行的时候可能被优先级更高的线程中断。
而有些操作是不能被中断的,不然会出现无法还原的后果,这时候,这些操作就需要原子操作。就是不能被中断的操作。
P操作:如果有可用的资源(信号量>0),那么占用一个资源(信号量-1)。如果没有可用的资源(信号量=0),则进程被阻塞,直到系统重新给他分配资源。
V操作:如果在该信号量的等待队列中有进程在等待该资源,则唤醒一个进程,否则释放一个资源(信号量+1)
POSIX提供两种信号量,有名信号量和无名信号量,有名信号量一般是用在进程间同步,无名信号量一般用在线程间同步。
两种信号量的操作流程,大概有下面的几点不同:
主要在于两种信号量初始化和销毁的方式不同。
还有一点是非常需要注意的,和在操作共享内存时需要连接库一样,在编译信号量的时候,也需要加上-pthread参数。
10.4.1有名信号量
1、创建有名信号量:
创建或者打开一个信号量,需要使用sem_open()函数,函数原形如下:
sem_t sem_open(const char * name, int oflag, mode_t mode,unsigned intvalue)
返回值sem_t是一个结构,如果函数调用成功,则返回指向这个结构的指针,里面装着当前信号量的资源数。
参数name,就是信号量的名字,两个不同的进程通过同一个名字来进行信号量的传递。参数oflag,当他是o_CREAT时,如果name给出的信号量不存在,那么创建,此时必须给出mode和vaule。也可以指定O_EXCL
参数mode,很好理解,用来指定信号量的权限。
参数vaule,则是信号量的初始值。
2、关闭有名信号量:
关闭有名信号量所使用的函数是sem_close(sem_t *sem)
这个函数只有一个参数,意义也非常明显,就是指信号量的名字。
3、信号量操作:
在使用信号量时,有两个非常重要的操作
P操作:使用的函数是sem_wait(sem_t *sem)
如果信号量的值大于零,sem_wait函数将信号量减一,并且立即返回。如果信号量的值小于零,那么该进程会被阻塞在原地。
V操作:使用的函数是sem_post(sem_t *sem)
当一个进程使用完某个信号量时,他应该调用sem_post函数来告诉系统收回资源。sem_post函数和sem_wait函数的功能刚好相反,他会把指定的信号量加一
4、删除有名信号量:
当使用完有名信号后,需要调用函数sem_unlink来释放资源。
函数原形: int sem_unlink (const char *name)
10.4.2无名信号量
无名信号量常用于多线程间的同步,同时也用于相关进程间的同步。
1、初始化信号量
int sem_init(sem_t *sem, int pshared, unsigned int value); sem是要进行初始化的信号量
pshared==0用于同一进程下多线程的同步;
若pshared>0用于多个相关进程间的同步(即fork产生的)
value则是信号量的初始值。
2、获取信号量的值
int sem_getvalue(sem_t *sem, int *sval); 3、销毁信号量
int sem_destroy(sem_t *sem);
10.5死锁
10.5.1死锁产生的原因
死锁产生的原因大致有两个:
资源竞争
程序执行顺序不当
注意:死锁并不是锁,而是在使用锁的过程中遇到的一种现象
10.5.2死锁产生的必要条件
资源死锁可能出现的情况主要有
互斥条件:资源要么被分配给一个进程,要么资源是可用的
保持和等待条件:已经获取资源的进程被认为能够获取新的资源
不可抢占条件:分配给一个进程的资源不能强制的被其他进程抢占,它只能由占有它的进程显示释放
循环等待:死锁发生时,系统中一定有两个或者两个以上的进程组成一个循环,循环中的每个进程都在等待下一个进程释放的资源。