目录
前言
我的上一篇文章Linux进程间通信讲了IPC(进程间通信)的三种方式,管道、FIFO和消息队列,但是它们各有各的优缺点,本文会讲解IPC的另外三种方式。
一、共享内存
相比于前三个IPC方式,共享内存有什么不同?
我们可以假设两个人要进行交流,管道和FIFO就好像两人中间有一个水管,一方往里面放,另一方就只能拿;消息队列就好像一个人往箱子里面放纸条,另一个人从箱子里拿出纸条,读取完后再把纸条放回去(消息读完后不会消失,不同于管道);而共享内存就像两人中间有一张桌子,一个人往桌子上写东西,另一个人可以直接看到它写的(桌子对于两个人来说是共用的)。
由名字可知,两个进程可以挂载同一个内存空间,这个内存空间是共享的。
相关函数:
#include <sys/shm.h>
//创建或获取一个共享内存:成功返回共享内存ID,失败返回-1
int shmget(key_t key, size_t size, int flag);
//连接共享内存到当前进程的地址空间:成功返回指向共享内存的指针,失败返回-1
void *shmat(int shm_id, const void *addr, int flag);
//断开与共享内存的连接:成功返回0,失败返回-1
int shmdt(void *addr);
//控制共享内存的相关信息:成功返回0,失败返回-1
int shmctl(int shm_id, int cmd, struct shmid_ds *buf);
关于shmget函数的第一个参数,我的上一篇博客是直接手动输入一个32位的整数,这里要用ftok函数获取唯一的一个键值:
#include <sys/types.h>
#include <sys/ipc.h>
key_t ftok(const char *pathname, int proj_id);
参数:
1.函数的第一个参数是一个路径名,通常是一个存在的文件路径,待会的代码示例中会传入".",这意味着 ftok 函数会使用当前进程的工作目录(即程序运行时所在的目录)作为路径来生成键值,我们只需要知道怎么使用就行了。
2.第二个参数proj_id,它用于进一步区分同一路径下不同对象(如消息队列、信号量、共享内存)的键值。在使用ftok函数时,传入的proj_id值应当是一个非零的整数。简单理解就是这个数字可以被视为一个简单的标识符,用于区分同一路径下不同的对象。
代码示例:
shmwrite.c
#include <stdio.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main()
{
int shmid;
char *shmaddr;
key_t key;
key=ftok(".",1);//获取键值
shmid=shmget(key,1024*4,IPC_CREAT|0666);//创建一个共享内存,权限为可读可写,大小为4兆
if(shmid==-1)//创建/获取共享内存失败
{
printf("shmget failed\n");
exit(-1);
}
shmaddr=shmat(shmid,0,0);//挂载共享内存,获取地址
strcpy(shmaddr,"hello!\n");//将字符串复制到共享内存里
sleep(5);//睡眠五秒
shmdt(shmaddr);//取消挂载/卸载共享内存
shmctl(shmid,IPC_RMID,0);//删除共享内存
return 0;
}
下面有几项要注意的地方:
1.创建共享内存时,空间大小必须以兆为单位,即1024字节,shmget函数的第二个参数一般传入IPC_CREAT,还需要|上创建的权限(0666表示可读可写,0777表示可读可写可执行)。
2.挂载共享内存shmat的第二和第三个参数通常写0即可,第二个参数写0表示让Linux内核为我们自动安排共享内存,第三个表示挂载/映射的共享内存为可读可写。
3.往共享内存里写数据用strcpy函数,需要包含string.h头文件,它的功能是将一个字符串复制到另一个字符串,由于我们定义的共享内存指针是char型的,所以可以用这个函数。
4.删除共享内存的第三个参数通常写0,表示不接收删除共享内存的信息等。
shmread.c
读和写的代码其实大差不差,读共享内存可以直接用printf函数,传入共享内存指针即可。
#include <stdio.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main()
{
int shmid;
char *shmaddr;
key_t key;
key=ftok(".",1);
shmid=shmget(key,0,0);//前面创建了共享内存,这里直接获取id就行了,所以后面参数写0
if(shmid==-1)
{
printf("shmget failed\n");
exit(-1);
}
shmaddr=shmat(shmid,0,0);
printf("from shm: %s",shmaddr);//传入指针
shmdt(shmaddr);//卸载
return 0;
}
读的代码不需要删除共享内存,由写的代码来完成,先执行写的代码,睡眠五秒后会删除共享内存空间。
运行结果:
先执行w,在5秒内执行r,成功打印信息,之后再执行r,由于没有创建共享内存,所以打印错误信息。
共享内存补充
在终端输入指令 ipcs -m 来查看系统中有哪些共享内存
输入 ipcrm -m shmid shmid为共享内存的ID,用于删除共享内存
共享内存有个小缺陷,就是两个人不能同时写,不然数据会混在一起,所以共享内存一般都结合信号量来使用,一个人写的时候另一个人只能看。
二、信号
信号概述
1.信号的名字和编号
每个信号都有一个名字和编号,这些名字都以“SIG"开头,例如"SIGIO","SIGCHLD"等等。
信号定义在 signal.h 头文件中,信号名都定义为正整数。
具体的信号名称可以使用 kill -l 来查看信号的名字以及序号,信号是从1开始编号的,不存在0号信号。kil对于信号0有特殊的应用。
(使用 kill -l 指令查看信号的名字以及序号)
假设我们写了一个无限循环的程序,想要让它停止运行,我们会按 Ctrl+C 键来终止进程,其实就是向进程发送了第2个信号SIGINT。
还有一种杀死进程的方式,另外打开一个终端,输入 ps -aux|greq 可执行文件名 来查找正在运行的进程ID,再输入 kill -9 进程ID 即可杀死进程,并且在终端打印Killed,kill是发送信号的指令,-9表示发送第9个信号,也就是发送SIGKILL信号给对应ID的进程。以上说的这些指令都需要我们掌握,因为后面的代码示例都是根据这些指令来写的。
2.信号的处理
信号的处理有三种方法,分别是:忽略、捕捉和默认动作
·忽略信号,大多数信号可以使用这个方式来处理,但是有两种信号不能被忽略(分别是 SIGKILL 和 SIGSTOP )。因为他们向内核和超级用户提供了进程终止和停止的可靠方法,如果忽略了,那么这个进程就变成了没人能管理的的进程,显然是内核设计者不希望看到的场景。
·捕捉信号,需要告诉内核,用户希望如何处理某一种信号,说白了就是写一个信号处理函数,然后将这个函数告诉内核。当该信号产生时,由内核来调用用户自定义的函数,以此来实现某种信号的处理。
·系统默认动作,对于每个信号来说,系统都对应有默认的处理动作,当发生了该信号,系统会自动执行。不过,对系统来说,大部分的处理方式都比较粗暴,就是直接杀死该进程。
总的来说,信号就是一个软中断,和我们单片机一样,某个动作出发了中断,进而执行中断里面的内容(硬件中断)。
信号相关函数入门版
信号处理函数的注册
#include <signal.h>
//它定义了一个名为sighandler_t的新类型(typedef),该类型是一个指向函数的指针,这个函数接受一个整型参数 int,并且没有返回值(void)
typedef void (*sighandler_t)(int)
sighandler_t signal(int signum, sighandler_t handler);
signal就是我们要使用的函数,第二个参数传入我们自定义的函数的函数名。
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
信号处理发送函数
#include <signal.h>
#include <sys/types.h>
int kill(pid_t pid, int siq);//第一个参数传入进程ID,第二个参数传入信号的编号
这个kill函数实现的功能其实和kill指令一样,但运用场景不同。话不多说我们上代码示例会更好理解。
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
1.使用signal函数实现Ctrl+C无法终止进程
#include <stdio.h>
#include <signal.h>
void handler(int signum)//自定义函数,参数为整形,无返回值
{
printf("get signum=%d\n",signum);//打印信号编码
printf("never quit\n");
}
int main()
{
signal(SIGINT,handler);//检测SIGINT信号(Ctrl+C),检测到了进入handler函数
while(1);
return 0;
}
首先看我们定义的函数,函数的参数会将触发该函数的信号的编号传进来,它由操作系统传入。
详细说明一下代码的步骤:当代码运行起来时,程序就跑到while(1)的位置了,所以信号是个软中断的说法也被证实了,当检测到SIGINT信号时,即用户按下啃臭C,就会进入用户定义的handler函数,操作系统会将信号的编号传入该函数,即使你的函数里没有写任何东西,这个信号也不会实现它原本的功能。(除了 SIGKILL 和 SIGSTOP !!!,至于为什么看前面信号概述讲忽略信号那一部分)
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
2.使用kill函数在程序内部执行kill指令
#include <stdio.h>
#include <signal.h>
#include <sys/types.h>
#include <stdlib.h>
int main(int argc, char **argv)
{
int signum;
int pid;
signum = atoi(argv[1]);//将字符串转换为整数类型
pid = atoi(argv[2]);
kill(pid,signum);
printf("send signal ok\n");
return 0;
}
运行结果:
左边的终端执行死循环的程序,右边再打开一个终端,查看进程的ID后执行编译好的pro可执行文件,传入信号编号和进程ID,左边会打印Killed,和kill指令实现功能一样。
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
信号相关函数高级版
信号处理函数的注册pro
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
参数:
1.信号编号,要接收哪个信号?
2.是一个指向 struct sigaction 类型的结构体指针,结构体原型为:
struct sigaction {
void (*sa_handler)(int); // 指定信号处理函数
void (*sa_sigaction)(int, siginfo_t *, void *); // 详细信号处理函数
sigset_t sa_mask; // 额外的要阻塞的信号集合
int sa_flags; // 特殊标志
void (*sa_restorer)(void); // 未使用,以备将来扩展
};
!!!通常配置结构体的第二个和第四个参数即可,第四个参数设置成 SA_SIGINFO ,这表示在使用sigaction函数设置信号处理时,希望使用详细的信号处理函数 sa_sigaction 而不是简单的处理函数 sa_handler ,因为我们只设置结构体的第二个和第四个参数,所以自然选择这个配套在一起,第二个参数传入自定义的函数名即可。
3.传入NULL即可
讲完了各个参数,那么现在应该编写用户自定义的函数了(这里还是有点绕的,还是需要一步步剖析源码,自己梳理一遍,等会结合代码示例来看),根据 struct sigaction 结构体的第二个参数可知,用户定义的函数原型应为:
void handler(int signum, siginfo_t *info, void *context);
参数:
1.信号的编号,由操作系统传入。
2.siginfo_t是一个结构体,它定义在 <signal.h> 头文件中,这个结构体包含了关于信号更详细的信息,使得信号处理函数能够获取有关信号发生背景的更多信息。
可以看到siginfo结构体包含了很多参数,代码示例我们只需要接受这两个参数,它会将接受到的整型数放在这里,而 si_value 又是一个结构体,整型数据会存放在 si_value.sival_int 里面。
3.第三个参数用来判断空或者非空(有无收到数据)
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
信号处理发送函数pro
#include <signal.h>
int sigqueue(pid_t pid, int siq, const union sigval value);
参数:
1.传入要接收信号的进程的进程ID。
2.要发送的信号的编号。
3.第三个参数是个联合体(二选一),原型如下:
union sigval {
int sival_int; // 整数值作为附加数据
void *sival_ptr; // 指针作为附加数据
};
之所以叫高级版,就是可以实现进程间收发数据,入门版的函数更像是发送某些指令,而高级版可以发送信号的同时发送数据,要么发送整形数,要么发送字符串等。
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
代码示例
讲了函数的相关参数,可能看的一头雾水,但是结合示例来看,我们只用掌握一些基础的用法即可,不需要太深入的了解,等实际用到的时候再去查找相关资料就行。
proread.c
#include <stdio.h>
#include <signal.h>
void handler(int signum, siginfo_t *info, void *context)//用户自定义函数
{
printf("get signum %d\n",signum);//打印信号编号
if(context != NULL)//如果内容非空
{
printf("get data = %d\n",info->si_int);//打印收到的数据
printf("get data = %d\n",info->si_value.sival_int);//打印收到的数据
}
}
int main()
{
struct sigaction act;//定义struct sigaction类型的结构体
/*
* 只需要配置结构体的两个参数,实现最基础的功能
*/
act.sa_sigaction = handler;//传入自定义函数名
act.sa_flags = SA_SIGINFO;
sigaction(SIGUSR1,&act,NULL);//检测SIGUSR1信号,参数二传入结构体指针,参数三通常写NULL
printf("%d\n",getpid());//打印一下进程的ID,方便write函数发送信号和数据
while(1);//不让程序退出
return 0;
}
只要认真看看注释,跟着上面的函数参数过一遍,应该是很好理解的,要配置的东西也不是很多,毕竟我们只实现最基本的功能,有一点要注意的是用户自定义的函数:因为第二个参数是个结构体指针,所以读取里面的数据要用 -> ,其实高级版和入门版的函数可以类比来学习,入门版自定义函数的参数会由操作系统来传入,高级版只不过我们可以获取的数据更多样罢了。
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
prowrite.c
#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
int main(int argc, char **argv)
{
int signum;
pid_t pid;
/*
* 将main函数传入的字符串转换为整形数
*/
signum = atoi(argv[1]);
pid = atoi(argv[2]);
union sigval value;//定义联合体
value.sival_int = 100;//发送整形数100
sigqueue(pid,signum,value);//第三个参数直接传入value即可
return 0;
}
运行结果:
左边先运行read函数,此时会打印进程的ID号,右边再运行write函数,第一个参数传入信号的编号,SIGUSR1的编号是10,再传入进程ID,这时右边就会打印到收到的数据和信号编号,因为发送的是整型数,所以他会存在两个地方(见proread.c用户自定义函数部分)
三、信号量
信号量概述
信号量(semaphore)与已经介绍过的 IPC 结构不同,它是一个计数器。信号量用于实现进程间的互斥与同步,而不是用于存储进程间通信数据。信号量无法实现进程间收发数据,举个例子来说明信号量的作用。
假设有一个房子,房子外有一个箱子,里面有一把钥匙,a想进入房子就要拿到钥匙然后进去,这时b也想进这个房子,但是b没有钥匙,所以b只能等a出来把钥匙放回箱子后再拿钥匙开锁进房子。那么这就涉及到信号量的几个概念。
·信号量--钥匙
·临界资源--房子
·p操作--拿到钥匙
·v操作--放回钥匙
·信号量集--箱子
以上就是信号量最主要的概念,Linux 下的信号量函数都是在通用的信号量数组上进行操作,而不是在一个单一的二值信号量上进行操作,所以就需要信号量集,有些大型的程序会管理很多个信号量,相当于信号量的集合(实际是一个数组,一个箱子可以有很多把钥匙)。
临界资源就是大家共享的资源,但是同一时间只希望一个人使用,比如共享内存,同一时间我只希望一方写数据,另一方只能读数据。以上这些操作就需要用到信号量,涉及同步与互斥的概念。
信号量相关函数
#include <sys/sem.h>
#include <sys/types.h>
#include <sys/ipc.h>
//创建或获取一个信号量组:若成功返回信号量集ID,失败返回-1
int semget(key_t key, int nsems, int semflg);
//对信号量数组进行操作,改变信号量的值:成功返回0,失败返回-1
int semop(int semid, struct sembuf *sops, size_t nsops);
//控制信号量的相关信息
int semctl(int semid, int semnum, int cmd, ...);
代码示例
先讲函数的参数个人感觉不太好理解,所以先写代码,结合代码来看为什么参数要这么设置;代码功能:正常fork出一个子进程后,往往是父进程先运行,通过信号量实现子进程先运行。
#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include <unistd.h>
union semun//联合体的原型
{
int val; /* Value for SETVAL */
struct semid_ds *buf; /* Buffer for IPC_STAT, IPC_SET */
unsigned short *array; /* Array for GETALL, SETALL */
struct seminfo *__buf; /* Buffer for IPC_INFO */
};
/*
* 步骤5
*/
void p(int semid)//p操作
{
struct sembuf set;//定义结构体
set.sem_num = 0;
set.sem_op = -1;
set.sem_flg = SEM_UNDO;
semop(semid,&set,1);
printf("get key\n");
}
/*
* 步骤6
*/
void v(int semid)//v操作
{
struct sembuf set;
set.sem_num = 0;
set.sem_op = 1;
set.sem_flg = SEM_UNDO;
semop(semid,&set,1);
printf("put back key\n");
}
int main(int argc, char **argv)
{
/*
* 步骤1
*/
key_t key;
key = ftok(".",2);
/*
* 步骤2
*/
int semid;
semid = semget(key,1,IPC_CREAT|0666);
/*
* 步骤3
*/
union semun initsem;
initsem.val = 0;//设置联合体的val为0
semctl(semid,0,SETVAL,initsem);//传入定义的联合体变量
/*
* 步骤4
*/
int pid=fork();
if(pid>0)//父进程
{
p(semid);//拿钥匙
printf("father\n");//进房子
v(semid);//放钥匙
}
else if(pid == 0)//子进程
{
printf("child\n");//直接进房子
v(semid);//放钥匙
}
return 0;
}
我们先看main函数:
步骤1:首先用ftok函数获取唯一的键值,第二个参数写"2",因为前面的共享内存写了"1",我这两个文件是在同一个目录下的,所以要区分。
步骤2:使用 semget 函数创建一个信号量组,我们只需要一个信号量,所以第二个参数写"1",第三个参数应该很熟悉了,和创建共享内存一样,传入 IPC_CREAT 这个宏,权限 0666 表示可读可写。
步骤3:创建完信号量组,就要对其进行初始化操作。使用 semctl 函数,第一个参数传入信号量组ID,第二个参数表示初始化第几个信号量,因为是数组,所以传入"0"。重点:semctl函数可以有三个或四个参数,第四个参数是否要填取决于第三个,这里cmd传入的是SETVAL表示要初始化信号量的值,那么第四个参数就要定义一个联合体(代码开头)。用联合体类型定义完变量后,给联合体的val参数赋值为0(只用这个参数,"0"表示没钥匙,"1"表示有钥匙!!!)
步骤4:fork出子进程,前面的初始化都做完了,现在来想一下代码思路:既然父进程会先执行,那我就让父进程去拿钥匙,初始化成没钥匙,那父进程就会进不去(阻塞状态),子进程不需要拿钥匙,直接进屋子(在p操作和v操作之间的代码我们看作是进屋子),子进程出来之后把钥匙放进箱子(这里不管子进程是从哪里得到的钥匙,我们开头说了,信号量实际上是个计数器,通过对信号量加减的操作来控制钥匙是否存在于箱子中),此时父进程才能拿到钥匙进入房子。
步骤5:现在来写封装p操作,需要用到 semop 函数,来对信号量进行操作,函数的第二个参数是一个结构体类型的数组,因为我们只有一个信号量,所以定义一个结构体就行了。
查看官方的示例,定义结构体后要配置三个参数,那我们也照葫芦画瓢。第一个参数是信号量的编号,我们只有一个信号量,给它0就好(看你喜欢),重点是第二个参数,信号量操作,通常是-1(P操作)、+1(V操作)或其他自定义操作,根据p或v操作后的值来判断有没有钥匙。第三个参数写 SEM_UNDO 可以增加系统的健壮性和稳定性,确保在任何情况下都能正确处理信号量的操作。
步骤6:和步骤5一样,只不过放回钥匙。
至此信号量的具体使用就讲完了,来看一下运行结果:
确实实现了想要的功能。
总结
至此,进程间通信的方式都讲完了,这篇博客也是我写过的最长的一篇博客,涉及到了很多复杂的c语言代码,一套又一套,大伙可以看看注释,自己敲一遍,搞清楚步骤就不会这么难了,主要是函数比较多,很多参数不知道是用来干嘛的,我上面的示例都配置了一些常用的参数,应该能用于大部分场景了,等做到某些很复杂的项目时再去研究其他参数的作用,接下来我会更新Linux线程编程的相关内容,可以在评论区讨论一下,大家一起学习!