进程间通信与线程间通信
一、进程、线程、协程
1、进程
进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
进程是一个具有独立功能的程序关于某个数据集合的一次运行活动。它可以申请和拥有系统资源,是一个动态的概念,是一个活动的实体。它不只是程序的代码,还包括当前的活动,通过程序计数器的值和处理寄存器的内容来表示。
进程的概念主要有两点:第一,进程是一个实体。每一个进程都有它自己的地址空间,一般情况下,包括文本区域(text region)、数据区域(data region)和堆栈(stack region)。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动过程调用的指令和本地变量。第二,进程是一个“执行中的程序”。程序是一个没有生命的实体,只有处理器赋予程序生命时(操作系统执行之),它才能成为一个活动的实体,我们称其为进程。
进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动,进程是系统进行资源分配和调度的一个独立单位。每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。
2、线程
线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位.线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。
一个线程可以创建和撤消另一个线程,同一进程中的多个线程之间可以并发执行。由于线程之间的相互制约,致使线程 在运行中呈现出间断性。线程也有就绪、阻塞和运行三种基本状态。就绪状态是指线程具备运行的所有条件,逻辑上可以运行,在等待处理机;运行状态是指线程占有处理机正在运行;阻塞状态是指线程在等待一个事件(如某个信号量),逻辑上不可执行。每一个程序都至少有一个线程,若程序只有一个线程,那就是程序本身。
线程是程序中一个单一的顺序控制流程。进程内一个相对独立的、可调度的执行单元,是系统独立调度和分派CPU的基本单位指运行中的程序的调度单位。在单个程序中同时运行多个线程完成不同的工作,称为多线程。
3、协程
协程是一种用户态的轻量级线程,协程的调度完全由用户控制。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。
4、三者之间的区别
4.1、子进程和子线程的概念
对于进程来说,子进程是父进程的复制品,从父进程那里获得父进程的数据空间,堆和栈的复制品。
而线程,相对于进程而言,是一个更加接近于执行体的概念,可以和同进程的其他线程之间直接共享数据,而且拥有自己的栈空间,拥有独立序列。
4.2、进程和线程的共同点
它们都能提高程序的并发度,提高程序运行效率和响应时间。线程和进程在使用上各有优缺点。 线程执行开销比较小,但不利于资源的管理和保护,而进程相反。同时,线程适合在SMP机器上运行,而进程可以跨机器迁移。
4.2、进程、线程的不同点
多进程中每个进程有自己的地址空间,线程则共享地址空间。
所有其他区别都是因为这个区别产生的。比如说:
- 地址空间:线程是进程内的一个执行单元,进程内至少有一个线程,它们共享进程的地址空间,而进程有自己独立的地址空间。
- 资源拥有:进程是资源分配和拥有的单位,同一个进程内的线程共享进程的资源,线程的资源利用率更好。
- 线程是处理器调度的基本单位,但进程不是。
- 每个独立的线程有一个程序运行的入口、顺序执行序列和程序的出口,但是线程不能够独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制。
5)速度:线程产生的速度快,通讯快,切换快,因为他们处于同一地址空间。
6)线程使用公共变量或者内存的时候需要同步机制,但进程不用。而他们通信方式的差异也仍然是由于这个根本原因造成的。
4.3、线程、协程比较
- 一个线程可以多个协程,一个进程也可以单独拥有多个协程,这样python中则能使用多核CPU。
- 线程进程都是同步机制,而协程则是异步
- 协程能保留上一次调用时的状态,每次过程重入时,就相当于进入上一次调用的状态
二、进程与线程的通信方式
1、通信方式之间的差异
实际上只有进程间需要通信,同一进程的线程共享地址空间,没有通信的必要,但要做好同步/互斥,保护共享的全局变量。
而进程间通信无论是信号,管道pipe还是共享内存都是由操作系统保证的,是系统调用。
2、进程间通信
2.1、管道通信(pipe)
2.1.1、管道的创建
管道是由调用pipe函数来创建
#include <unistd.h>
int pipe (int fd[2]);
//返回:成功返回0,出错返回-1
fd参数返回两个文件描述符,fd[0]指向管道的读端,fd[1]指向管道的写端。fd[1]的输出是fd[0]的输入。
2.1.2、管道如何实现进程间的通信
- step1、父进程创建管道,得到两个⽂件描述符指向管道的两端。
- step2、父进程fork出子进程,⼦进程也有两个⽂件描述符指向同⼀管道。
- step3、父进程关闭fd[0],子进程关闭fd[1],即⽗进程关闭管道读端,⼦进程关闭管道写端(因为管道只支持单向通信)。⽗进程可以往管道⾥写,⼦进程可以从管道⾥读,管道是⽤环形队列实现的,数据从写端流⼊从读端流出,这样就实现了进程间通信。
2.1.3、代码示例
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>
int main()
{
int fd[2];
int ret = pipe(fd);
if (ret == -1)
{
perror(”pipe error\n”);
return 1;
}
pid_t id = fork();
if (id == 0)
{//child
int i = 0;
close(fd[0]);
char *child = “I am child!”;
while (i<5)
{
write(fd[1], child, strlen(child) + 1);
sleep(2);
i++;
}
}
else if (id>0)
{//father
close(fd[1]);
char msg[100];
int j = 0;
while (j<5)
{
memset(msg,’\0’,sizeof(msg));
ssize_t s = read(fd[0], msg, sizeof(msg));
if (s>0)
{
msg[s - 1] = ’\0’;
}
printf(”%s\n”, msg);
j++;
}
}
else
{//error
perror(”fork error\n”);
return 2;
}
return 0;
}
2.1.4、管道通信的特点
1.管道只允许具有血缘关系的进程间通信,如父子进程间的通信。
2.管道只允许单向通信。
3.管道内部保证同步机制,从而保证访问数据的一致性。
4.面向字节流
5.管道随进程,进程在管道在,进程消失管道对应的端口也关闭,两个进程都消失管道也消失。
2.2、消息队列(pipe)
2.3、共享内存(shared memory)
共享内存就是允许两个或多个进程共享一定的存储区。就如同 malloc() 函数向不同进程返回了指向同一个物理内存区域的指针。当一个进程改变了这块地址中的内容的时候,其它进程都会察觉到这个更改。因为数据不需要在客户机和服务器端之间复制,数据直接写到内存,不用若干次数据拷贝,所以这是最快的一种IPC。
共享内存没有任何的同步与互斥机制,所以要使用信号量来实现对共享内存的存取的同步。
2.3.1、共享内存相关函数
- 头文件
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
- 创建共享内存——shmget()函数
int shmget(key_t key, size_t size, int shmflg);
//成功返回共享内存的ID,出错返回-1
key:
key是长整型(唯一非零),系统建立IPC通讯 ( 消息队列、 信号量和 共享内存) 时必须指定一个ID值。通常情况下,该id值通过ftok函数得到,由内核变成标识符,要想让两个进程看到同一个信号集,只需设置key值不变就可以。
size:
size指定共享内存的大小,它的值一般为一页大小的整数倍(未到一页,操作系统向上对齐到一页,但是用户实际能使用只有自己所申请的大小)。
shmflg:
shmflg主要和一些标志有关。其中有效的包括IPC_CREAT和IPC_EXCL,它们的功能与open()的O_CREAT和O_EXCL相当。
IPC_CREAT 如果共享内存不存在,则创建一个共享内存,否则打开操作。
IPC_EXCL 只有在共享内存不存在的时候,新的共享内存才建立,否则就产生错误。
如果单独使用IPC_CREAT,shmget()函数要么返回一个已经存在的共享内存的操作符,要么返回一个新建的共享内存的标识符。如果将IPC_CREAT和IPC_EXCL标志一起使用,shmget()将返回一个新建的共享内存的标识符;如果该共享内存已存在,或者返回-1。IPC_EXEL标志本身并没有太大的意义,但是和IPC_CREAT标志一起使用可以用来保证所得的对象是新建的,而不是打开已有的对象。对于用户的读取和写入许可指定SHM_R和SHM_W,(SHM_R>3)和(SHM_W>3)是一组读取和写入许可,而(SHM_R>6)和(SHM_W>6)是全局读取和写入许可。
- 操作共享内存——shmctl()函数
int shmctl(int shm_id, int cmd, struct shmid_ds *buf);
//成功返回0,出错返回-1
(1)第一个参数,shm_id是shmget函数返回的共享内存标识符。
(2)第二个参数,cmd是要采取的操作,它可以取下面的三个值 :
IPC_STAT:把shmid_ds结构中的数据设置为共享内存的当前关联值,即用共享内存的当前关联值覆盖shmid_ds的值。
IPC_SET:如果进程有足够的权限,就把共享内存的当前关联值设置为shmid_ds结构中给出的值
IPC_RMID:删除共享内存段
(3)第三个参数,buf是一个结构指针,它指向共享内存模式和访问权限的结构。 shmid_ds结构至少包括以下成员
struct shmid_ds
{
uid_t shm_perm.uid;
uid_t shm_perm.gid;
mode_t shm_perm.mode;
};
- 挂接操作——shmat()函数
void *shmat(int shm_id, const void *shm_addr, int shmflg);
//成功返回指向共享存储段的指针,出错返回-1
(1)第一个参数,shm_id是由shmget函数返回的共享内存标识。
(2)第二个参数,shm_addr指定共享内存连接到当前进程中的地址位置,通常为空,表示让系统来选择共享内存的地址。
(3)第三个参数,shm_flg是一组标志位,通常为0
- 分离操作——shmdt()函数
该操作不从系统中删除标识符和其数据结构,要显示调用shmctl(带命令IPC_RMID)才能删除它
int shmdt(const void *shmaddr);
//成功返回0,出错返回-1
(1)addr参数是以前调用shmat时的返回值
2.3.2、代码示例
Server作为发送方,每次向共享内存中,写入A,Client作为接收方,每次读取共享内存中的数据:
shmdata.h
#ifndef _SHMDATA_H_HEADER
#define _SHMDATA_H_HEADER
#define TEXT_SZ 2048
struct shared_use_st
{
int written; // 作为一个标志,非0:表示可读,0:表示可写
char text[TEXT_SZ]; // 记录写入 和 读取 的文本
};
#endif
shmread.c
#include <stddef.h>
#include <sys/shm.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include "shmdata.h"
int main(int argc, char **argv)
{
void *shm = NULL;
struct shared_use_st *shared; // 指向shm
int shmid; // 共享内存标识符
// 创建共享内存
shmid = shmget((key_t)1234, sizeof(struct shared_use_st), 0666|IPC_CREAT);
if (shmid == -1)
{
fprintf(stderr, "shmat failed\n");
exit(EXIT_FAILURE);
}
// 将共享内存连接到当前进程的地址空间
shm = shmat(shmid, 0, 0);
if (shm == (void *)-1)
{
fprintf(stderr, "shmat failed\n");
exit(EXIT_FAILURE);
}
printf("\nMemory attached at %X\n", (int)shm);
// 设置共享内存
shared = (struct shared_use_st*)shm; // 注意:shm有点类似通过 malloc() 获取到的内存,所以这里需要做个 类型强制转换
shared->written = 0;
while (1) // 读取共享内存中的数据
{
// 没有进程向内存写数据,有数据可读取
if (shared->written == 1)
{
printf("You wrote: %s", shared->text);
sleep(1);
// 读取完数据,设置written使共享内存段可写
shared->written = 0;
// 输入了 end,退出循环(程序)
if (strncmp(shared->text, "end", 3) == 0)
{
break;
}
}
else // 有其他进程在写数据,不能读取数据
{
sleep(1);
}
}
// 把共享内存从当前进程中分离
if (shmdt(shm) == -1)
{
fprintf(stderr, "shmdt failed\n");
exit(EXIT_FAILURE);
}
// 删除共享内存
if (shmctl(shmid, IPC_RMID, 0) == -1)
{
fprintf(stderr, "shmctl(IPC_RMID) failed\n");
exit(EXIT_FAILURE);
}
exit(EXIT_SUCCESS);
}
shmwrite.c
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <sys/shm.h>
#include "shmdata.h"
int main(int argc, char **argv)
{
void *shm = NULL;
struct shared_use_st *shared = NULL;
char buffer[BUFSIZ + 1]; // 用于保存输入的文本
int shmid;
// 创建共享内存
shmid = shmget((key_t)1234, sizeof(struct shared_use_st), 0666|IPC_CREAT);
if (shmid == -1)
{
fprintf(stderr, "shmget failed\n");
exit(EXIT_FAILURE);
}
// 将共享内存连接到当前的进程地址空间
shm = shmat(shmid, (void *)0, 0);
if (shm == (void *)-1)
{
fprintf(stderr, "shmat failed\n");
exit(EXIT_FAILURE);
}
printf("Memory attched at %X\n", (int)shm);
// 设置共享内存
shared = (struct shared_use_st *)shm;
while (1) // 向共享内存中写数据
{
// 数据还没有被读取,则等待数据被读取,不能向共享内存中写入文本
while (shared->written == 1)
{
sleep(1);
printf("Waiting...\n");
}
// 向共享内存中写入数据
printf("Enter some text: ");
fgets(buffer, BUFSIZ, stdin);
strncpy(shared->text, buffer, TEXT_SZ);
// 写完数据,设置written使共享内存段可读
shared->written = 1;
// 输入了end,退出循环(程序)
if (strncmp(buffer, "end", 3) == 0)
{
break;
}
}
// 把共享内存从当前进程中分离
if (shmdt(shm) == -1)
{
fprintf(stderr, "shmdt failed\n");
exit(EXIT_FAILURE);
}
sleep(2);
exit(EXIT_SUCCESS);
}
分析:
1、程序shmread创建共享内存,然后将它连接到自己的地址空间。在共享内存的开始处使用了一个结构struct_use_st。该结构中有个标志written,当共享内存中有其他进程向它写入数据时,共享内存中的written被设置为0,程序等待。当它不为0时,表示没有进程对共享内存写入数据,程序就从共享内存中读取数据并输出,然后重置设置共享内存中的written为0,即让其可被shmwrite进程写入数据。
2、程序shmwrite取得共享内存并连接到自己的地址空间中。检查共享内存中的written,是否为0,若不是,表示共享内存中的数据还没有被完,则等待其他进程读取完成,并提示用户等待。若共享内存的written为0,表示没有其他进程对共享内存进行读取,则提示用户输入文本,并再次设置共享内存中的written为1,表示写完成,其他进程可对共享内存进行读操作。
关于前面的例子的安全性讨论:
这个程序是不安全的,当有多个程序同时向共享内存中读写数据时,问题就会出现。可能你会认为,可以改变一下written的使用方式,例如,只有当written为0时进程才可以向共享内存写入数据,而当一个进程只有在written不为0时才能对其进行读取,同时把written进行加1操作,读取完后进行减1操作。这就有点像文件锁中的读写锁的功能。咋看之下,它似乎能行得通。但是这都不是原子操作,所以这种做法是行不能的。试想当written为0时,如果有两个进程同时访问共享内存,它们就会发现written为0,于是两个进程都对其进行写操作,显然不行。当written为1时,有两个进程同时对共享内存进行读操作时也是如些,当这两个进程都读取完是,written就变成了-1.
要想让程序安全地执行,就要有一种进程同步的进制,保证在进入临界区的操作是原子操作。例如,可以使用前面所讲的信号量来进行进程的同步。因为信号量的操作都是原子性的。
2.3.3、使用共享内存的优缺点
1、优点:我们可以看到使用共享内存进行进程间的通信真的是非常方便,而且函数的接口也简单,数据的共享还使进程间的数据不用传送,而是直接访问内存,也加快了程序的效率。同时,它也不像匿名管道那样要求通信的进程有一定的父子关系。
2、缺点:共享内存没有提供同步的机制,这使得我们在使用共享内存进行进程间通信时,往往要借助其他的手段来进行进程间的同步工作。
2.4、信号量(semaphore)
信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
2.5、套接字(socket)
套接口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同设备及其间的进程通信。
2.6、信号 (sinal)
信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
3、线程间通信
3.1、锁机制:包括互斥锁、条件变量、读写锁
互斥锁提供了以排他方式防止数据结构被并发修改的方法。
读写锁允许多个线程同时读共享数据,而对写操作是互斥的。
条件变量可以以原子的方式阻塞进程,直到某个特定条件为真为止。对条件的测试是在互斥锁的保护下进行的。条件变量始终与互斥锁一起使用。
3.2、信号量机制(Semaphore)
包括无名线程信号量和命名线程信号量。
3.3、信号机制(Signal)
类似进程间的信号处理。
线程间的通信目的主要是用于线程同步,所以线程没有像进程通信中的用于数据交换的通信机制。