在前面的文章中(Linux编程基础——多线程),简单对Linux中的多线程进行了介绍,包括pthread、信号量与互斥锁,本文将对Linux编程中的多任务间通信与同步技术进行相对完整的补充。
在Linux中有两种多任务实现手段:进程和线程。
- 由于进程是工作在独立的内存空间中,不同的进程间不能直接访问到对方的内存空间,因此需要通过某种方式来通信。
- 而同一进程内的线程共享内存空间,很容易实现数据共享,但需要严格控制多线程对同一内存地址的访问,因此需要采用一定的方式来进行同步。
Linux中主要提供了以下的一些方式来实现多任务通信和同步:
- 信号。信号是在软件层次上对中断机制的一种模拟,用于通知接受进程(或线程)某事件的发生。
- 管道及有名管道。管道勇于具有亲缘关系的进程间的通信。有名管道,除具有管道的全部功能外,还允许无亲缘关系的进程间的通信。
- 消息队列。消息队列是消息的链接表,克服了前两种通信方式中信息量有限的缺点,具有写权限的进程可以向消息队列中按照一定的规则添加新消息;消息队列有读权限的进程则可以从消息队列中读取消息。
- 共享内存。主要用于进程间通信。使得多个进程可以访问同一块内存空间,不同进程可以及时看到共享内存中数据的更新。在一些多核的系统应用中,也基于共享内存实现共享。
- 套接字。更一般的通信机制,可用于不同机器之间的进程间通信。在相关网络通信介绍中均会有所涉及。
- 信号量。是一个可以用来控制多个进程存取共享资源的计数器。其经常作为一种锁定机制来防止当一个进程正在存取共享资源时,另一个进程也存取同一资源。
- 互斥锁。主要用于线程同步,可以对共享资源加锁,任何其他试图在此对互斥量加锁的线程将会被阻塞直至当前线程释放该互斥锁,保证每次只有一个线程可以对共享资源访问。
其中,前5种主要用于多任务间通信,后2种主要用于多任务间同步。
1. 信号
信号(Signal)是Linux操作系统中用于进程间通信和进程内部通信的一种机制。信号是由内核向进程发送的一种异步通知(类似中断),表示某个事件已经发生或者某个条件已经满足。每个信号都对应一个唯一的整数信号值,例如SIGINT表示中断信号。
Linux中的型号包括两种类型:同步信号和异步信号。
- 同步信号,是由某个进程自己发出的,例如在程序中调用kill函数发送信号给自己或其他进程;
- 异步信号,由内核发出给进程的,例如由硬件中断或其他进程的型号发送给本进程。
信号涉及头文件<signal.h>
,将信号都定义为整数。
信号名称 | 信号定义 |
---|---|
SIGINT | 终端中断,终止进程的中断信号,通常由CTRL+C触发 |
SIGTERM | 终止进程的请求信号,通常由kill命令发送 |
SIGKILL | 停止进程,强制终止进程的信号,无法被忽略、阻塞或处理,通常用于终止僵尸进程和崩溃的进程 |
SIGSTOP | 停止执行,暂停进程的信号,无法被忽略、阻塞或处理,通常由CTRL+Z触发 |
SIGTSTP | 终端停止信号,暂停进程的信号,可以被忽略、阻塞或处理,通常由CTRL+Z触发 |
SIGCONT | 如果被停止则继续执行,恢复进程的信号,通常用于从暂停状态中恢复进程 |
SIGHUP | 系统挂断,终端挂起或断开的信号,通常用于重新读取配置文件或重启进程 |
SIGUSR1 | 自定义信号1,可以用于进程间通信 |
SIGUSR2 | 自定义信号2,可以用于进程间通信 |
信号的生命周期
从信号发送到信号处理函数执行完毕的全过程称为信号的生命周期。可以分为三个重要的阶段,主要事件:信号产生;信号在进程中注册;信号在进程中注销;信号处理函数执行完毕。
- 信号产生。指触发信号的事件发生(如硬件异常、定时器超时以及调用信号发送函数
kill()
或sigqueue()
等。)
#include <sys/types.h>
#include <signal.h>int kill(pid_t pid, int signo);
其中signo为要发送的信号值。调用成功返回0,否则返回-1。
#include <signal.h>int sigqueue(pid_t pid, int signo, const union sigval value);
其中,signo为要发送的信号类型,value是一个union类型,用于传递信号的附加信息。
- 信号在进程中注册。 注册是指进程知道需要处理某个信号,但还没来得及处理,或该信号被进程阻塞,则先将该信号保存到某个链表中(如未决信号链)。
- 信号在进程中注销。注销是指进程等待处理某个信号,且该信号没有被进程阻塞,则在运行相应的信号处理函数前,进程会把信号从未决信号链中卸载。
- 信号处理。进程注销信号后,立即执行相应的信号处理函数,执行完毕后,信号的本次发送对进程的影响彻底结束。
用户进程对于信号的响应可以有以下三种处理方式:
- 忽略信号:即对信号不做任何处理,但有两个信号不能忽略,SIGKILL和SIGSTOP。
- 捕捉信号:定义信号处理函数,当信号发生时,执行相应的处理函数。
- 执行默认操作:Linux对每种信号都规定了默认的动作。
signal()函数
void (*signal(int sig, void (*func)(int)))(int);//可替换为以下方式理解typedef void sign(int);sign *signal(int, handler *);
关于signal函数的使用:
- 定义一个信号处理函数,其函数形式为:
void func(int)
; - 调用signal函数,将要处理的信号和型号处理函数作为参数传递进去,进行处理。
// 对信号SIGINT的响应函数
void sig_int(int sig) {printf("Received signal %d\n", sig);// 进行信号处理
}
// 注册信号
signal(SIGINT, sig_int);
当收到Ctrl+C组合键时,将会打印信号“Received signal 2”。
2. 管道
管道(“|”)是Linux中一种非常强大的特性,是进程间通信(IPC,Inter Process Communication),用于将一个命令的输出作为另一个命令的输入。通过使用管道,可以将多个命令串联在一起,从而实现更多的复杂操作。
无名管道(PIPE),可用于具有亲缘关系进程间的通信。
有名管道(FIFO),除具有管道所具有的功能外,还允许无亲缘关系进程间的通信。
关于(无名)管道的特性:
- 管道是半双工的,数据只能向一个方向流动:需要双方通信时,需要建立起两个管道;
- 只能用于父子进程或兄弟进程之间(具有亲缘关系的进程);
- 管道对于管道两端的进程而言,就是一个文件,但不是普通的文件,不属于某个文件系统,而是单独构成一种文件系统,且只存在于内存中。
- 一个进程向管道中写的内容被管道另一端的进程读出。
2.1 管道(pipe)的使用
管道是由系统调用pipe()
函数创建,具体使用操作包括创建、写、读、关闭。
步骤1:创建管道
#include <unistd.h>int pipe(int pipefd[2]);
pipe函数用于创建一对无名的管道文件描述符。
- pipefd[0]:管道读取端
- pipefd[1]:管道写入端
//用于保存无名管道的文件描述符
int pipefd[2];
if(pipe(pipefd)<0)
{
//创建管道失败
}
步骤二:写管道
#include <stdio.h>
char buf[BUFSIZ];
//将buf中内容写入到管道
write(pipefd[1], buf, BUFSIZ);
步骤三:读管道
//从管道中读取内容到buf
rcount = read(pipefd[0], buf, BUFSIZ);
可以看到,通过创建之后的管道文件描述符进行管道的操作。
步骤四:关闭管道
在管道操作完成之后,通过close函数关闭管道。
close(pipefd[0]);
close(pipefd[1]);
2.2 FIFO的使用
FIFO,也成为有名管道(Named Pipe),是Linux中一种特殊类型的文件。它提供一个路径名与之关联,以FIFO的文件形式存在于文件系统中。因此,即使与FIFO创建进程不存在亲缘关系的进程,只要可以访问该路径,就能够彼此通过FIFO相互通信。
另外,从FIFO的命名来看,可知道FIFO管道中数据的方式为,先进先出,(First In First Out),即读从开始处返回,写则添加到末尾。
FIFO的操作包括,创建、打开和关闭、写与读、删除等操作。
创建FIFO
使用mkfifo命令创建FIFO文件。
int mkfifo(const char *pathname, mode_t mode);
创建成功返回0。模式方式有:
- O_RDONLY,读管道;
- O_WRONLY,写管道;
- O_RDWR,读写管道;
- O_NONBLOCK,非阻塞;
- O_CREAT,如果该文件不存在,就创建一个新的文件;
- O_EXCL,如果配合O_CREAT时文件存在,返回错误信息。
#include <unistd.h>
//创建FIFO之前,通过access函数来检查文件是否存在,或权限
int access(const char *pathname, int mode);//当文件检查失败失败则调用mkfifo创建
if(access(FIFO_NAME, F_OK)==-1)
{res = mkfifo(FIFO_NAME, 0777);
}
打开和关闭FIFO
创建FIFO文件之后,可以使用文件IO操作打开FIFO,语法为 fd = open(pathname, flags)
,其中pathname为FIFO文件的路径名,flags为打开文件的方式。
打开成功后,可以使用read和write函数向FIFO中写入和读取数据。
关闭FIFO使用close函数,语法为 close(fd)
使用cat和echo向FIFO中写入数据
在文件中,则使用write(pipe_fd, buf, BUFFER_SIZE
来写入。
命令行操作,可以使用cat和echo命令向FIFO中写入数据,语法为echo "data" > filename
和cat file.txt > filename
。
使用tail和cat从FIFO中读取数据
对于管道的另一端,则通过read函数读取FIFO中数据。
在命令行操作中,可以使用tail和cat命令从FIFO中读取数据,语法为tail -f filename
和cat filename
。
如果有进程写打开FIFO,且当前FIFO内没有数据,则对于设置了阻塞标志的读操作来说,将一直阻塞。对于没有设置阻塞标志的读操作来说则返回-1。
3. 共享内存
共享内存是一种高效的进程间通信方式。两个不同的进程A、B共享内存的意义是通过映射之后,在进程地址空间访问同一块物理内存。A、B之间可以及时看到共享内存中数据的更新。
共享内存的一种实现方式是通过mmap()
系统调用。通过mmap()
系统调用使得进程之间通过映射同一个普通文件实现共享内存。普通文件被映射到进程地址空间后,进程可以像访问普通内存一样对文件进行访问,不必再调用read、write等操作。mmap系统调用配合使用的系统调用还有munmap()
、msync()
等,函数原型定义如下:
#include <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 len);
//实现磁盘文件内容与共享内存区内存同步,保持一致
int msync(void *addr, size_t len, int flags);
一般来说,进程在映射空间的对共享内容的改变并不直接写回到磁盘文件中,往往在调用munmap()
后才执行该操作。可以通过msync()实现磁盘上文件内容与共享内存区的内容一致。
除系统调用mmap以外,Linux中还引入了System V共享内存。
内存专门留出了一块共享内存区域,所有需要访问该共享区域的进程都要把该共享区域映射到本进程的地址空间中。每个内存区域都有一个标示符(shmid),进程通过这个标示符访问内存区域。
- 首先创建或打开一个共享内存对象,可以使用shmget()函数或者shm_open()函数。
#include <sys/shm.h>
int shmget(key_t key, size_t size, int flag);
void *shmat(int shmid, const void *addr, int flag);
void shmdt(void *addr);
-
分配一段内存空间来存储共享内存对象,可以使用shmat()函数将内存挂接到当前进程的地址空间中。
-
对挂接到进程地址空间的内存进行读写操作,可以使用memcpy()等相关函数。
-
使用shmdt()函数将共享内存对象从进程地址空间中分离,使其不再被当前进程使用。
-
最后使用shmctl()函数对共享内存对象进行进一步的控制,如删除共享内存对象、查询共享内存信息等。
需要注意的是,在使用共享内存时需要对共享内存进行加锁保护,避免多个进程同时对共享内存进行读写产生竞争和错误。此外,在多进程环境下,使用共享内存需要使用信号量等同步机制来保证进程间的同步和互斥。