最近更新于 2024-05-05 14:19
一个大型应用程序,往往需要众多进程协作,进程间通信的重要性显而易见。Linux 下的进程通信手段基本上是从 UNIX 平台上的进程通信手段继承而来的,而对 UNIX 发展做出重大贡献的两大主力 —— AT&T 的贝尔实验室和 BSD (加州大学伯克利分校的伯克利软件发布中心)在进程间通信方面的侧重点有所不同。前者对 UNIX 早期的进程间通信手段进行了系统的改进和扩充,形成了”system V IPC“,通信进程局限在单个计算机内;后者则跳过了该限制,形成了基于套接字(Socket)的进程通信机制。其中 UNIX IPC 包括管道,FIFO 和信号,System V IPC 包括 System V 消息队列、System V 信号灯和 System V 共享内存区,Posix IPC 包括 Posix 消息队列、Posix 信号灯 和 Posix 共享内存区。
(1)由于 UNIX 版本的多样性,电子电气工程协会(IEEE)开发了一个独立的 UNIX 标准,这个新的 ANSI UNIX 标准被称为计算机环境的可移植性操作系统界面(POSIX)。现有的大部分 UNIX 和流行版本遵循的都是 POSIX 标准,而 Linux 从诞生就遵循 POSIX 标准。
(2)BSD 并不是没有涉足单机内的进程通信(Socket 本身就可以用于单片机内的进程间通信)。事实上,很多 UNIX 版本的单机 IPC 留有 BSD 的痕迹。
进程间通信概述
目的
(1)数据传输:一个进程需要将它的数据发送给另外一个进程,发送的数据量在1B至几MB之间。
(2)共享数据:多个进程想要操作共享数据,若一个进程对共享数据进行修改,别的进程也能立即看到。
(3)通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(比如子进程终止时会发送信号通知父进程)。
(4)资源共享:多个进程之间共享同样的资源。为了做到这一点,需要内核提供锁和同步机制。
(5)进程控制:有些进程希望完全控制另一个进程的执行(如 Debug 进程),此时进程控制希望能够拦截另一个进程的所有信息和异常,并能够及时知道它的状态。
简介
通信方式 | 说明 |
管道(Pipe)和有名管道(FiFO) | 管道可以用于具有亲缘关系进程间通信,有名管道克服了管道没有名字的限制,因此,它还可以用于无亲缘关系进程间通信。 |
信号(Signal) | 信号是比较复杂的通信方式,用于通知接收进程有某种事件发生,除了用于进程间通信外,进程还可以发送信号给本身;Linux 除了支持 UNIX 早期信号语义函数 signal 外,还支持语义符合 POSIX.1 标准的信号函数 sigaction (实际上,该函数是基于 BSD 的,BSD 为了在实现可靠信号机制的同时又能够统一对外接口,用 sigaction 函数重新实现了 signal 函数)。 信号是在软件层次上对终端机制的一种模拟是一种异步通信方式。 信号可以在直接进行用户空间进程和内核进程之间的交互,内核进程也可以利用它来通知用户空间进程发生了哪些系统上事件,它可以在任何时候发给某一进程,而无需知道该进程的状态。 如果该进程当前并未处于执行态,则该信号就由内核保存起来,直到该进程恢复执行再传递给它;如果一个信号被进程设置为阻塞,则该信号的传递被延迟,直到其阻塞不取消时才传递给进程。 |
消息队列 | 消息队列是消息的链接表,包括 POSIX 消息队列和 System V 消息队列。有足够权限的进程可以向队列中添加消息,被赋予读权限的进程则可以读走队列中的消息。消息队列克服了信号承载信息量少、管道只能承接无格式字节流,以及缓冲区大小受限等缺点。 |
信号量(Semaphore)/信号灯 | 信号量主要被用作进程间或同一进程不同线程之间的同步手段。信号量是用来解决进程之间同步与互斥问题的一种进程之间的通信机制,包括一个称为信号量的变量和在该信号量下等待资源的进程等待队列,以及对信号量进行的两个原子操作(PV 操作)。其中信号量对应于某一种资源,取一个非负的整型值。信号量的值是指当前可用的资源数量,若它等于 0 则意味着目前没有可用的资源。 |
共享内存(Shared Memory) | 共享内存可以说是最有用的进程间通信的方式,也是最快的。两个不同进程A、B共享内存的意思是,同一块物理内存被映射到 A 和 B 各自的进程地址空间中。进程 A 可以即时看到进程 B 对共享内存中的更新,反之亦然。由于多个内存共享一块内存区域,必然需要某种同步机制,互斥锁和信号量都可以。采用共享内存通信的一个显而易见的好处是效率高,因为进程可以直接读写内存,而不需要任何数据的复制。 |
套接字(Socket) | 套接字是更为一般的进程间通信机制,可用于不同机器之间的进程通信。起初是由 UNIX 系统的 BSD 分支开发出来的,但现在一般可以移植到其它类 UNIX 系统上,Linux 和 System V 的变种都支持套接字。 |
管道通信
读写无名管道
管道的两端一个用一个大小为 2 的 int 数组来描述,下标0、1分别对应读、写。
从管道中读取数据的步骤如下:
(1)如果管道的写端不存在,则认为已经读到了数据的末尾,read 读数据返回值为 0;
(2)若管道的写端存在,如果请求的字节数大于 PIPE_BUF,则返回管道中现有的数据字节数;如果请求的字节数不大于 PIPE_BUF,则返回管道中现有的数据字节数(此时,管道中数据量小于请求的数据量),或者返回请求的字节数(此时,管道中数据量不小于请求的数据量)。
PIPE_BUF 在 include/linux/limits.h 中定义,不同的内核版本可能有所不同,可以使用命令 ulimit -a 查询,其中 pipe size,我这里就是 512B x 8 = 4KB
/** * @author IYATT-yx * @date 2021-5-12 * @brief 管道示例1 - 管道读规则验证 - 在管道写端被关闭后,写入的数据将一直存在,直到被读出为止。 */ #include <stdio.h> #include <unistd.h> #include <sys/types.h> int main(void) { // pipe file descriptor int pfd[2]; // 创建管道 if (pipe(pfd) < 0) { perror("pipe error"); return 0; } // 创建进程 pid_t pid = fork(); // 父进程 // 读 if (pid > 0) { // 关闭写端 close(pfd[1]); // 休眠 sleep(3); // 从管道读数据 char rbuf[10]; if (read(pfd[0], rbuf, sizeof(rbuf)) > 0) { printf("父进程收到子进程的信息: %s\n", rbuf); } else { perror("父进程读取管道失败"); close(pfd[0]); return 0; } close(pfd[0]); } // 子进程 // 写 else if (pid == 0) { // 关闭读端 close(pfd[0]); // 向管道写数据 char wbuf[] = "IYATT-yx"; if (write(pfd[1], wbuf, sizeof(wbuf)) > 0) { printf("子进程向管道写入数据\n"); } else { perror("子进程 write error"); close(pfd[1]); return 0; } close(pfd[1]); printf("子进程关闭管道写端\n"); sleep(5); } else { perror("fork error"); return 0; } }
写端对读端存在依赖性。在向管道中写入数据时,必须确保存在一个进程中有读端,否则可能会出现 Broken pipe 的错误(管道破裂,进程收到 SIGPIPE 信号,默认动作是终止进程)。
管道的原子性。一个管道的容量是有限的。POSIX规定,少于 PIPE_BUF 的写操作必须原子完成:要写的数据应被连续的写到管道;大于 PIPE_BUF 的写操作可能是非原子的: 内核可能会把此数据与其它进程的对此管道的写操作交替起来。POSIX规定PIPE_BUF至少为512B(linux中为4KB)。
- 当要写入的数据量不大于PIPE_BUF时,linux将保证写入的原子性。
- 当要写入的数据量大于PIPE_BUF时,linux将不再保证写入的原子性。
无名管道的应用
用于 shell,管道可用于输入输出的重定向,它将一个命令的输出直接重定向到另一个命令的输入。
比如: ls / | grep “b”
以及“有亲缘”的进程之间通信
/** * @author IYATT-yx * @date 2021-5-12 * @brief 管道重定向示例 - 父子进程通信 */ #include <stdio.h> #include <sys/types.h> #include <unistd.h> #include <string.h> int main(int argc, char **argv) { if (argc != 4) { printf("参数错误\n"); return 0; } if (strcmp(argv[1], "-")) { printf("请用 - 作为管道连接符号\n"); return 0; } // 管道 int pfd[2]; // 创建管道 if (pipe(pfd) == -1) { perror("pipe error"); return 0; } // 创建子进程 pid_t pid = fork(); // 父进程 - 读管道 if (pid > 0) { // 关闭写端 close(pfd[1]); // 用于读管道的缓冲 char buf[12]; // 持续从管道中读取数据并显示到终端 while ((read(pfd[0], buf, sizeof(buf)))) { printf("%s", buf); // 清空 memset(buf, 0, sizeof(buf)); } close(pfd[0]); return 0; } // 子进程 - 写管道 else if (pid == 0) { // 关闭读端 close(pfd[0]); // 重定向输出到管道写端 dup2(pfd[1], STDOUT_FILENO); // 参数通过命令行参数传入给 ls execlp(argv[2], "", argv[3], NULL); perror("execlp error"); } else { perror("fork error"); return 0; } }
有名管道
管道最大的限制就是它没有名字,因此,只能用于“有亲缘”的进程之间通信,在有名管道(FIFO)提出后,解决了这个不足。FIFO 不同于管道之处在于它提供了一个路径名与之关联,以 FIFO 的文件形式存在于文件系统中。这样,即使与 FIFO 的创建进程不存在亲缘关系的进程,只要可以访问该路径,就能通过 FIFO 相互通信,因此不相关的进程也能通过 FIFO 交换数据。值得注意的是,FIFO 严格遵循先进先出(First In First Out),对管道和 FIFO 的读取总是从开始处返回数据的,对管道和 FiFO 的写入则把数据添加到末尾,不支持 lseek 等读写位置偏移操作。
int mkfifo(const char *pathname, mode_t mode); ---- 创建有名管道
从 FIFO 中读取
如果一个进程为了从 FIFO 中读取数据而阻塞打开了 FIFO,那么称该进程内的读操作为设置了阻塞标志的读操作。
(1) 如果有进程写入打开 FIFO,且当前 FIFO 内没有数据,则对于设置了阻塞标志的读操作来说,将一直阻塞。对于没有设置阻塞标志的读操作来说则返回 -1 ,errno 设为 EAGAIN。
(2)对于设置了阻塞标志的读操作来说,造成阻塞的原因有两种:① 当前 FIFO 内有数据,但有其它进程在读这些数据;② FIFO 内没有数据。不阻塞的原因是 FIFO 中有新的数据写入,不论写入数据的大小,也不论读操作请求多少数据量。
(3) 读操作打开的阻塞标志只对本进程第一个读操作施加作用,如果本进程内有多个读操作序列,则在第一个读操作被唤醒并完成后,其它将要执行的读操作将不再阻塞。
(4)如果没有进程写入打开 FIFO,则设置了阻塞标志的读操作会阻塞。
向 FIFO 中写入数据
如果一个进程为了向 FIFO 中写入数据而阻塞打开了 FIFO,那么称该进程内的写操作为设置了阻塞标志的写操作。
(1)对于设置了阻塞标志的写操作:① 当要写入的数据量不大于 PIPE_BUF 时,Linux 将保证写入的原子性。如果此时管道空闲缓冲区不足以容纳要写入的字节数,则进入睡眠状态,直到缓冲区中能够容纳要写入的字节数时,才开始进行一次写操作。② 当要写入的数据量大于 PIPE_BUF 时,Linux 将不再保证写入的原子性。FIFO 缓冲区一有空闲区域,就会试图向管道中写入数据,写完后返回。
(2)对于没有设置阻塞标志的写操作:① 当要写入的数据量大于 PIPE_BUF 时,Linux 将不保证写入的原子性。在写满所有 FIFO 空闲缓冲区后,写操作返回。② 当要写入的数据量不大于 PIPE_BUF 时,Linux 将保证写入的原子性。如果当前 FIFO 空闲缓冲区能够容纳写入的字节数,则写完后返回成功;如果当前 FIFO 空缓冲区不能容纳请求写入的数据大小,则返回 EAGAIN。
信号
信号及信号来源
信号的本质:
信号是在软件层次上对终端机制的一种模拟,在原理上,一个进程收到一个信号与处理器收到一个终端请求可以说是一样的。信号是异步的,一个进程不必通过任何操作来等待信号的到达,事实上,进程也不知道信号到底什么时候到达。
信号是进程间通信机制中唯一的异步通信机制,可以看作异步通知,通知接收信号的进程有哪些事件发生了。信号机制通过 POSIX 实时扩展后,功能更加强大,除了基本通知功能,还可以传递附加信息。
信号的来源:
信号事件的发生有两个来源:① 硬件(比如按下了键盘或者硬件故障);② 软件,最常用的发送信号的系统函数是 kill、raise、alarm、setitimer 和 sigqueue,还包括一些非法运算操作等。
信号种类
查看信号:前往 shell 编程 的 用 “trap 命令捕捉信号” 部分.
不可靠信号:
Linux 信号机制基本上是从 UNIX 中继承过来的,在早期的 UNIX 中信号机制比较简单和原始,后来在实践中暴露出一些问题,因此把那些建立在早期机制上的信号叫做不可靠信号(一般 1~31 为不可靠信号,31 ~ 64 为不可靠信号,前面 “shell 编程” 有不可靠信号的详情图)。对于这些不可靠信号,进程每次处理信号后,就将对信号的响应设置为默认动作。在某些情况下,这将导致对信号的错误处理,因此,如果用户不希望这样操作,就要在信号处理函数的结尾再次调用 signal(),重新安装该信号。
Linux 支持不可靠信号,但是对不可靠信号机制做了改进:在调用完信号处理函数后,不必重新调用该信号的安装函数(信号安装函数是在可靠机制上实现的),因此,Linux 下的不可靠信号主要是指信号可能丢失。
可靠信号:
随着时间的发展,实践证明了有必要对信号的原始机制加以改进和扩充,所以,后来各种 UNIX 版本分别在这方面进行了研究,力图实现可靠信号。由于原来定义的信号已有许多应用,不好再做改动,最终只好又增加了一些信号,并在一开始就把它们定义为可靠信号,这些信号支持排队,不会丢失。同时,信号的发送和安装也出现了新版本:sigqueue() 和信号安装函数 sigaction 。POSIX.4 对可靠信号机制做了标准化,但是只对可靠信号机制应具有的功能和信号机制的对外接口做了标准化,对信号机制的实现没有做具体的规定。
Linux 在支持新版本的信号安装函数 sigaction 和信号发送函数 sigqueue 的同时,仍然支持早期的信号安装函数 signal 和信号发送函数 kill。注意信号的可靠与不可靠和发送、安装函数无关,只与具体的信号值有关,即 1~31 号信号固定为不可靠信号,之后的信号为可靠信号。
另外,非实时信号都不支持排队,都是不可靠信号;实时信号都支持排队,都是可靠信号。
信号处理方式
(1)忽略信号。即对信号不做任何处理,但是其中有两个信号是无法被忽略的(SIGKILL 和 SIGSTOP)。
(2)捕捉信号。定义信号处理函数,当信号发生时,执行相应的处理函数。
(3)执行默认操作。Linux 对每种信号都规定了默认操作,注意,进程对实时信号的默认操作是进程终止。
信号发送
int kill(pid_t pid, int sig); pid > 0 将信号传给进程识别码为 PID 的进程 pid = 0 将信号传给和当前进程相同进程组的所有进程 pid == -1 将信号广播传送给系统内所有的进程 pid < 0 将信号传给进程组识别码为 PID 绝对值的所有进程 unsigned alarm(unsigned seconds); 该函数用于设置 SIGALRM,在经过参数 seconds 秒后传送给当前进程。如果设置参数为 0,则之前设置的信号会被取消,并返回剩下的时间 void (*signal(int sig, void (*func)(int)))(int); 该函数根据 sig 指定的信号编码来设置该信号的处理函数,当指定的信号到达时就会跳转到参数 func 指定的函数执行 如果 func 不是函数指针,则必须设置为下列两者之一: SIG_IGN 忽略 sig 信号 SIG_DFL 将 sig 指定的信号重设为默认处理方式 int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact); // sa_flags 为 0 ,则使用第一个 // sa_flags 为 1 ,则使用第二个 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_mask 用来设置在处理信号时将 sa_mask 指定的信号搁置,直到执行完回调函数再处理 oact 参数如果不是设置的 NULL,则返回原来信号的处理方式 int sigqueue(pid_t pid, int sig, const union sigval value); union sigval { int sival_int; void *sival_ptr; }; int setitimer(int which, const struct itimerval *new_value, struct itimerval *old_value); struct itimerval { // 周期 struct timeval it_interval; // 首次 struct timeval it_value; }; struct timeval { // 秒 time_t tv_sec; // 微秒 suseconds_t tv_usec; }; void abort(void); abort()函数首先解除进程对SIGABRT信号的阻止,然后向调用进程发送该信号。abort()函数会导致进程的异常终止除非SIGABRT信号被捕捉并且信号处理句柄没有返回。
/** * @author IYATT-yx * @date 2021-5-14 * @brief kill 函数使用示例 */ #define _GNU_SOURCE #include <signal.h> #include <stdio.h> #include <sys/types.h> #include <unistd.h> int main(void) { pid_t pid = fork(); // 父进程 if (pid > 0) { // 等待 3 秒后消灭死循环的子进程 sleep(3); // 父进程中 fork() 返回值为子进程的 ID // 9 号 SIGKILL 信号不可捕捉和忽略,收到该信号的进程会被无条件杀死 kill(pid, SIGKILL); } // 子进程 else if (pid == 0) { int counter = 0; while (1) { ++counter; printf("计数: %d\n", counter); sleep(1); } } else { perror("fork error"); } }
/** * @author IYATT-yx * @date 2021-5-14 * @brief alarm 和 signal 函数的使用示例 */ #include <unistd.h> #include <signal.h> #include <stdio.h> void handler(int sig) { printf("捕捉到信号: %d\n", sig); } int main(void) { // 捕捉 SIGALRM 并执行设定的函数 signal(SIGALRM, handler); // 3秒后发送 SIGALRM 信号 alarm(3); // 计数器 int counter = 0; // 计数 while (counter < 5) { ++counter; printf("%d\n", counter); sleep(1); } }
/** * @author IYATT-yx * @date 2021-5-15 * @brief sigaction 的使用 * 按 Ctrl + \ 向程序发送 SIGQUIT 信号会被捕捉到 */ #define _GNU_SOURCE #include <stdio.h> #include <signal.h> #include <unistd.h> void handler(int sig) { printf("捕捉到信号:%d\n", sig); } int main(void) { struct sigaction sig; sig.sa_flags = 0; sigemptyset(&sig.sa_mask); sig.sa_handler = handler; sigaction(SIGQUIT, &sig, NULL); while (1) { sleep(1); } }
信号集
信号集被定义为一种数据类型
typedef struct { unsigned long sig[_NSIG_WORDS]; } sigset_t;
信号集用来描述信号的集合,Linux 所支持的所有信号可以全部或部分地出现在信号集中,主要与信号阻塞相关地函数配合使用。
int sigemptyset(sigset_t *set); ---- 用于初始化信号集,将 set 清空 int sigfillset(sigset_t *set); ---- 用于将所有信号加入信号集 int sigaddset(sigset_t *set, int signum); ---- 用于增加一个信号到信号集 int sigdelset(sigset_t *set, int signum); ---- 用于从信号集中删除一个信号 int sigismember(const sigset_t *set, int signum); ---- 测试某个信号是否已加入信号集里
使用信号注意事项
(1)防止不该丢失地信号丢失。
(2)程序地可移植性。考虑到程序地可移植性,应该尽量采用 POSIX 信号函数,POSIX 信号函数主要分为两类。
① POSIX 1003.1 信号函数包含 kill、sigaction、sigaddset、sigdelset、sigemptyset、sigfillset、sigismember、sigpending、sigprocmask 和 sigsuspend。
② POSIX 1003.1b 信号函数:在信号地实时性方面对 ① 做了扩展,包括 sigqueue、sigtimedwait 和 sigwaitinfo 3 个函数,其中 sigqueue 主要对信号发送,而 四个timedwait 和 sigwaitinfo 主要用于取代 sigsuspend。
(3)程序地稳定性。为了增强程序地稳定性,在信号处理程序中应当使用可再入函数(一个可以被多个任务调用地过程,任务在调用时不必担心数据是否出错)。因为进程在收到信号后,就会跳转到信号处理函数去执行。如果信号处理函数中使用了不可重入的函数,那么信号处理函数可能会修改原来进程中不应该被修改的数据,这样进程从信号处理函数中返回接着执行时,可能会出现不可预料的后果。不可再入函数在信号处理函数中被视为不安全函数。如下:
- 使用静态的数据结构,如 getlogin、gmtime……
- 在函数实现时,调用了 malloc 或者 free。
- 在函数实现时使用了标准 I/O 库函数,如 _exit、access、alarm……
即使信号处理函数使用的都是“安全函数”,同样要注意在进入处理函数时,要先保存 errno 的值,结束时再恢复原值,这是因为在信号处理过程中,errno 的值随时可能被改变。
消息队列
消息队列(也叫报文队列)能够克服早期 UNIX 通信机制的一些缺点。作为早期 UNIX 通信机制之一的信号能够传送的信息量有限,后来虽然 POSIX 1003.1b 在信号的实时性方面做了拓广,使得信号在传递信息量方面有了相当程度的改进。但是信号更像是“即时”的通信方式,它要求接收信号的进程在某个时间范围内对信号做出反应,因此该信号最多在接收信号进程的声明周期内才有意义,信号所传递的信息是接近于随进程持续的概念,管道和有名管道则是典型的随进程持续 IPC,并且只能传送无格式的字节流,这无疑会给应用程序开发带来不便,另外,它的缓冲区大小也受到限制。
消息队列就是一个消息的链表。可以把消息看作记录,具有特定的格式和特定的优先级。对消息队列有写权限的进程可以按照一定的规则添加新消息,对消息队列有读权限的进程则可以从消息队列中读走消息,消息队列是随内核持续的。
目前主要有两种类型的消息队列——POSIX 消息队列和 System V 消息队列。System V 消息队列目前被大量使用,考虑到程序的可移植性,新开发的应用程序应尽量使用 POSIX 消息队列。
消息队列基础理论
System V 消息队列是随内核持续的,只有在内核重启或者显示删除一个消息队列的时候才会真正被删除。因此系统中记录消息队列的数据结构(struct ipc_ids msg_ids)位于内核中,系统中的所有消息队列都可以在结构 msg_ids 中找到访问入口。
消息队列就是一个消息的链表。每个消息队列都有一个队列头,用结构 struct msg_queue 来描述。队列头中包含了该消息队列的大量信息,包括消息队列键值、用户 ID、组 ID 和消息队列中消息数目等,甚至记录了最近对消息队列读写进程的 ID。
struct kern_ipc_perm { key_t key; uid_t uid; gid_t gid; uid_t cuid; gid_t cgid; mode_t mode; unsigned long seq; };
使用消息队列
对消息队列的操作有下面 3 种类型。
(1)打开或创建消息队列。消息队列的内核持续性要求每个消息队列都在系统范围内对应唯一的键值,所以,要获得一个消息队列的描述字(由在系统范围内唯一的键值生成的,而键值可以看作对应系统内的一条路径。),只需要提供该消息队列的键值即可。
(2)读写操作。消息读写操作非常简单,对开发人员来说,每个消息都类似如下的数据结构:
struct msgbuf { long mtype; char mtext[1]; };
mtype 代表消息类型,从消息队列种读取消息的一个重要依据就是消息的类型;mtext 表示消息内容,长度不一定为 1.因此,对于发送消息来说,需要预置一个 msgbuf 缓冲区并写入消息类型和内容,调用相应的发送函数即可;对读取消息来说,首先分配这样一个 msgbuf 缓冲区,然后把消息读入该缓冲区即可。
(3)获得或设置消息队列属性。消息队列的消息基本上保存在消息队列头中,因此,可以分配一个类似于消息队列头的结构,来返回消息队列的属性,同样可以设置该数据结构。