Process Communication
进程通信
线程之间共享全局变量、文件描述符表、堆区资源等。通信较为方便,但进程之间通信需要跨越虚拟内存的鸿沟。
进程之间通信种方式有
- 管道
- 消息队列
- 共享内存
- 信号量
- 信号
- Socket
接下来分别介绍他们和在Linux下的实现。
管道
匿名管道
在Linux下,命令中传递参数用的|就是匿名管道,例如
ps aux | grep mysql它将前一个命令ps aux的结果传递给下一个命令grep mysql作为输入。
命令管道/FIFO
使用命令mkfifo创建命名管道,也称FIFO,因为符合先入先出的规律。
mkfifo my_pipe在当前目录下创建一个管道文件my_pipe
可以向这个管道写入数据,例如echo
echo "hello" > my_pipe你操作了后,你会发现命令执行后就停在这了,这是因为管道里的内容没有被读取,只有当管道里的数据被读完后,命令才可以正常退出。
于是,我们执行另外一个命令来读取这个管道里的数据:
cat my_pipe
hello可以看到,管道里的内容被读取出来了,并打印在了终端上,另外一方面,echo 那个命令也正常退出了。
我们可以看出,管道这种通信方式效率低,不适合进程间频繁地交换数据。当然,它的好处,自然就是简单,同时也我们很容易得知管道里的数据已经被另一个进程读取了。
那么如何在咱们的代码中创建管道呢?
需要用到下面这个系统调用
int pipe(int fd[2]);这里表示创建一个匿名管道,并返回了两个描述符,一个是管道的读取端描述符 fd[0],另一个是管道的写入端描述符 fd[1]。注意,这个匿名管道是特殊的文件,只存在于内存,不存于文件系统中。
普通的pipe就只能在单进程里头使用,如下图
但结合fork即可在父进程和子进程之间通信。
父进程Linux示例代码:父进程写,子进程读为例。
#include <iostream>
#include <string>
#include <unistd.h>
int main() {
int fd[2];
pipe(fd);
auto ret = fork();
if (ret == 0) {
close(fd[1]);
printf("I' child.\n");
char buf[1024];
read(fd[0], buf, 1024); //阻塞在这, 等有数据再读。
printf("get messege: %s\n", buf);
} else {
close(fd[0]);
printf("I'm parent.\n");
sleep(5); //让读进程阻塞。
const std::string s = "Hello pipe reader";
write(fd[1], s.c_str(), s.size());
}
return 0;
}实际上,fork出来的子进程的fd是和父进程相同的。因此,父子进程操作的是同一个管道,即不能同时地双向通信。要双向通行可以创建两个管道。
总的来说,对于匿名管道,由于没有文件名,只有描述符,仅能通过fork复制fd来实现父子进程通信。
而命名管道则是一个实实在在的文件,有文件名。因此,两个进程可以通过文件名找到该管道来通信。
值得一提的是,不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时候自然也是从内核中获取,同时通信数据都遵循先进先出原则,不支持 lseek 之类的文件定位操作。
此外,匿名管道和FIFO读写都是默认阻塞的,如果要使用非阻塞模式,需要在pipe和open的第二个参数指定O_NONBLOCK,并根据返回值和错误号来判断具体情况做后续处理。
消息队列
前面提到管道的写方需要阻塞到数据被读取才能够返回,而消息队列则不需要。
对于写方,只需要写完数据即可。
对于读方,读取时需要阻塞到数据来临。
实例代码
// Message Queue (Writer Process)
#include <stdio.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#define MAX 10
// structure for message queue
struct mesg_buffer {
long mesg_type;
char mesg_text[100];
} message;
int main()
{
key_t key;
int msgid;
// ftok to generate unique key
key = ftok("progfile", 65);
// msgget creates a message queue
// and returns identifier
msgid = msgget(key, 0666 | IPC_CREAT);
message.mesg_type = 1;
printf("Write Data : ");
fgets(message.mesg_text,MAX,stdin);
// msgsnd to send message
msgsnd(msgid, &message, sizeof(message), 0);
// display the message
printf("Data send is : %s \n", message.mesg_text);
return 0;
}// Message Queue (Reader Process)
#include <stdio.h>
#include <sys/ipc.h>
#include <sys/msg.h>
// structure for message queue
struct mesg_buffer {
long mesg_type;
char mesg_text[100];
} message;
int main()
{
key_t key;
int msgid;
// ftok to generate unique key
key = ftok("progfile", 65);
// msgget creates a message queue
// and returns identifier
msgid = msgget(key, 0666 | IPC_CREAT);
// msgrcv to receive message
msgrcv(msgid, &message, sizeof(message), 1, 0);
// display the message
printf("Data Received is : %s \n",
message.mesg_text);
// to destroy the message queue
msgctl(msgid, IPC_RMID, NULL);
return 0;
}消息队列生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在,而前面提到的匿名管道的生命周期,是随进程的创建而建立,随进程的结束而销毁。
消息这种模型,两个进程之间的通信就像平时发邮件一样,你来一封,我回一封,可以频繁沟通了。
但邮件的通信方式存在不足的地方有两点,一是通信不及时,二是附件也有大小限制,这同样也是消息队列通信不足的点。
消息队列不适合比较大数据的传输,因为在内核中每个消息体都有一个最大长度的限制,同时所有队列所包含的全部消息体的总长度也是有上限。在 Linux 内核中,会有两个宏定义 MSGMAX 和 MSGMNB,它们以字节为单位,分别定义了一条消息的最大长度和一个队列的最大长度。
消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销,因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程。
共享内存
上面提到,消息队列涉及用户态与内核态之间的数据拷贝,显然这是不必要的。而共享内存的方式,就很好的去除了这个开销。
现代操作系统的内存采用的是虚拟内存技术,即每个进程的内存相互独立,互不干扰。而虚拟内存就是拿出来一块共享内存来通信。
示例代码
// Writer
#include <iostream>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <stdio.h>
using namespace std;
int main()
{
// ftok to generate unique key
key_t key = ftok("shmfile",65);
// shmget returns an identifier in shmid
int shmid = shmget(key,1024,0666|IPC_CREAT);
// shmat to attach to shared memory
char *str = (char*) shmat(shmid,(void*)0,0);
cout<<"Write Data : ";
gets(str);
printf("Data written in memory: %s\n",str);
//detach from shared memory
shmdt(str);
return 0;
}// Reader
#include <iostream>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <stdio.h>
using namespace std;
int main()
{
// ftok to generate unique key
key_t key = ftok("shmfile",65);
// shmget returns an identifier in shmid
int shmid = shmget(key,1024,0666|IPC_CREAT);
// shmat to attach to shared memory
char *str = (char*) shmat(shmid,(void*)0,0);
printf("Data read from memory: %s\n",str);
//detach from shared memory
shmdt(str);
// destroy the shared memory
shmctl(shmid,IPC_RMID,NULL);
return 0;
}共享内存不仅仅可以作为消息通信,还可以作为进程共享其他数据用于通信、同步、互斥等,例如共享内存的锁,信号量等等。
信号量
信号量不仅可以用于线程之间通信(原理和使用见[读者写者文章](./reader-writer control)),还可以在进程之间通信。
信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。
而信号量在进程之间通信主要依赖于共享内存,并强转成对应的类型。
代码同共享内存和[读者写着文章](./reader-writer control)类似,关键在于分配内存后强转成信号量类型。
sme_t *str = (sem_t*) shmat(shmid, NULL, 0);信号
信号和信号量是完全不同的东西,但也可以用于通信。不过,不同与其他方式,信号一般用于异常通信。
例如,可以在代码中使用system call kill 来关闭一个进程。
int kill(pid_t pid, int sig);或者直接在shell中使用kill。可以通过kill -l查看参数
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7
58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2
63) SIGRTMAX-1 64) SIGRTMAX常用的如
- Ctrl+C 产生
SIGINT信号,表示终止该进程; - Ctrl+Z 产生
SIGTSTP信号,表示停止该进程,但还未结束;
如果进程在后台运行,可以通过 kill 命令的方式给进程发送信号,但前提需要知道运行中的进程 PID 号,例如:
- kill -9 1050 ,表示给 PID 为 1050 的进程发送
SIGKILL信号,用来立即结束该进程;
所以,信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令)。
信号是进程间通信机制中唯一的异步通信机制,因为可以在任何时候发送信号给某一进程,一旦有信号产生,我们就有下面这几种,用户进程对信号的处理方式。
1.执行默认操作。Linux 对每种信号都规定了默认操作,例如,上面列表中的 SIGTERM 信号,就是终止进程的意思。
2.捕捉信号。我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。
3.忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILL 和 SEGSTOP,它们用于在任何时候中断或结束某一进程。
实际上,对于每个进程的信号处理函数,是可以自定义的。使用signal来注册。
//define my signal handler.
void sig_handler(int signum){
printf("\nInside handler function\n");
}
int main() {
singal(SIGINT, sig_handler); // register.
}实际上,Java虚拟机就用到了这一技巧,使得崩溃的线程不会导致JVM崩溃。
Socket
前面提到的种种方式,都是本地通信。要做到与不同主机的通信,就需要用到网络通信,就需要Socket了。
Socket编程对应两种网络协议,TCP和UDP。创建Socket需要指定相关参数
int socket(int domain, int type, int protocol);- domain 参数用来指定协议族,比如 AF_INET 用于 IPV4、AF_INET6 用于 IPV6、AF_LOCAL/AF_UNIX 用于本机;
- type 参数用来指定通信特性,比如 SOCK_STREAM 表示的是字节流,对应 TCP、SOCK_DGRAM 表示的是数据报,对应 UDP、SOCK_RAW 表示的是原始套接字;
- protocal 参数原本是用来指定通信协议的,但现在基本废弃。因为协议已经通过前面两个参数指定完成,protocol 目前一般写成 0 即可;
Socket相关的内容篇幅有点大,就不展开了。以后再写一篇文章吧。