一、进程通信
进程通信(IPC)就是进程之间的信息交换。
为了保证安全,一个进程不能直接访问另一个进程的地址空间。
IPC的方式通常有管道(包括无名管道和命名管道)、信号、消息队列、共享内存、信号量、Socket、Streams等。其中 Socket和Streams支持不同主机上的两个进程IPC。
管道
1.管道只能采用半双工通信,某一时间段内只能实现单向的传输。如果要实现双向同时通信,则需要设置两个管道。
2.各进程要互斥地访问管道。
3.数据以字符流的形式写入管道,当管道写满时,写进程的write()系统调用将被阻塞,等待读进程将数据取走。当读进程将数据全部取后,管道变空,此时读进程的read()系统调用将被阻塞。
4.如果没写满,就不允许读。如果没读空,就不允许写。
5.数据一旦被读出,就从管道中被抛弃,这就意味着读进程最多只能有一个,否则可能会有读错数据的情况。
✏️匿名管道
在Linux命令中,有这么一条命令:
ps auxf | grep mysql
在这个命令中,“ | ” 所代表的的就是一个管道,这条命令的意思是将前一个命令ps auxf的输出作为后一个命令grep mysql的输入。同时,它所代表的也是匿名管道,在用完之后就会销毁。
✏️命名管道
命名管道,就是有名字的管道,也被叫做FIFO,数据是先进先出的传输方式。
在创建命名管道时,通过mkfifo创建并指定管道的名字:
mkfifo pipeName
基于Linux一切皆文件的理念,所以命令管道也是以文件的形式存在的,可以通过ls来查看文件类型为p。
然后进行写入操作:
echo "hello world" > pipeName //将数据写入管道 但会停在这个命令处
在进行写入操作后只有当管道里面的数据被读取完后才能正常退出。
读取管道数据命令:
cat < pipeName
在读取之后就可以正常退出了。
✏️管道的优点与缺点
优点:简单明了,能够很容易的得知管道里面的数据已经被读取了。
缺点:效率低,不适合进程之间频繁的交换数据。
管道的原理:进程间的6种通信机制,管道、消息队列、共享内存、信号量、信号、Socket_进程间通信机制-优快云博客
消息队列
消息队列,可以理解为两个进程之间的通信是存在一个专门的队列来存放他们之间发送的信息的,如A进程要给B进程发送信息,那么A进程把数据信息放在对应的消息队列后就可以正常返回了,B进程需要的时候在消息队列进行读取。同理,B进程给A进程发送信息也是这样。有足够权限的进程可以向队列中添加消息,被赋予读权限的进程则可以读走队列中的消息。信号承载信息量少,管道通信中我们发现它不适合进程间频繁的交换数据,消息队列解决了这些问题。
- 消息队列是面向记录的,其中的消息具有特定的格式以及特定的优先级。
- 消息队列独立于发送与接收进程。进程终止时,消息队列及其内容并不会被删除。
- 消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取。
- 消息队列不适合比较大数据的传输,因为在内核中每个消息体都有一个最大长度限制,同时所有消息队列包含的全部消息体的总长度也有上限。
- 消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销(通信不及时)。进程在写入数据到内核中的消息队列时会发生从用户态拷贝数据到内核态的过程,另一进程读取信息时会发生从内核态拷贝到用户态的过程。
共享内存
在消息队列的通信方式中,我们指出了它存在的问题,其中用户态与内核态之间的数据拷贝开销(通信不及时)可以通过共享内存机制解决。这和之前内存管理中的虚拟内存原理相同。
共享内存的机制就是拿出一块虚拟空间来,映射到相同物理内存中。 当一个进程发生写入操作时另一个进程可以马上读取,不需要进行拷贝这一操作,大大提高了进程间的通信速度。
共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制配合使用,如信号量,来实现进程间的同步和通信。
信号量(semaphore)
共享内存的方式解决消息队列的不足的同时自己也带来了新的问题,那就是如果多个进程同时修改一个共享内存,很可能会产生问题。
为了防止多进程竞争共享资源而造成数据错乱,所以需要一种保护机制来使得共享资源在某一时刻只能被一个进程访问,这个机制就是信号量。它和管道有所不同,它不以传送数据为主要目的,它主要是用来保护共享资源(信号量也属于临界资源)。每个执行流在进入临界区之前都应该先申请信号量,申请成功就有了操作特定的临界资源的权限,当操作完毕后就应该释放信号量。
信号量是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据(不设置全局变量是因为进程间是相互独立的,而这不一定能看到,看到也不能保证++引用计数为原子操作)。
信号量的工作原理
由于信号量只能进行两种操作:等待和发送信号,即P(sv)和V(sv),他们的行为是这样的:
(1)P(sv):我们将申请信号量称为P操作,申请信号量的本质就是申请获得临界资源中某块资源的使用权限,当申请成功时临界资源中资源的数目应该-1,因此P操作的本质就是让计数器-1,如果sv>0,就给它-1;如果sv=0,就挂起该进程的执行
(2)V(sv):我们将释放信号量称为V操作,释放信号量的本质就是归还临界资源中某块资源的使用权限,当释放成功时临界资源中资源的数目就应该+1,因此V操作本质就是让计数器+1,如果有其他进程因等待sv而被挂起,就让它恢复运行,如果没有进程因等待sv而挂起,就给他+1
我们可以初始化信号量的值来实现进程间的互斥与同步,要实现进程间的互斥,则可以初始化信号量为1,要实现进程间的同步则可以初始化信号量为0。
当我们仅用一个互斥锁对临界资源进行保护时,相当于我们将这块临界资源看作一个整体,同一时刻只允许一个执行流对这块临界资源进行访问。但实际可以将这块临界资源再分割为多个区域,当多个执行流需要访问临界资源时,如果这些执行流访问的是临界资源的不同区域,那么我们可以让这些执行流同时访问临界资源的不同区域,此时不会出现数据不一致等问题。
PV操作必须是原子操作
信号(Signal)
以上四种都是正常情况下的进程间通信方式,而对于异常情况下的工作模式,就需要用到信号的方式通知进程。
在Linux系统中,提供了几十种信号以响应各种事件,可以通过kill -l命令查看。
信号事件的来源主要有硬件来源和软件来源:
运行在shell终端的进程我们可以通过键盘输入某些组合键给进程发送信号,如Ctrl+C产生SIGINT信号,表示终止该进程,Ctrl+Z产生SIGTSTP信号,表示停止该进程但还未结束。
进程如果在后台运行,可以通过kill命令发送信号,但前提是需要知道运行中进程的PID号,如kill -9 1050表示给PID为1050的进程发送SIGKILL信号,表示立即结束该进程。
信号是进程通信机制中唯一的异步通信机制,因为可以在任何时候发送信号给某一进程,一旦有信号产生,那么就有这三种处理信号的方式:
- 执行默认操作:Linux对于每种信号都规定了默认操作。
- 捕捉信号:可以将信号定义为一个信号处理函数,当信号发生时执行相应的函数。
- 忽略信号:不做任何处理。需要注意的是,有两个信号无法忽略,即SIGKILL和SEGSTOP,它们用于在任何时候中断或结束某一进程。
套接字(Socket)
前面五种进程间的通信方式(管道、消息队列、共享内存、信号量、信号)都是在同一台主机上进行进程间通信,Socket通信可以实现跨网络与不同主机上的进程之间通信。
我们先来看创建socket的系统调用:
int socket(int domain, int type, int protocat)
这三个参数代表的含义:
- domain参数用来指定协议族,如AF_INET用于IPv4、AF_INET6用于IPv6、AF_LOCAL/AF_UNIX用于本机。
- type参数用来指定通信特性,比如SOCK_STREAM表示的是字节流,对应TCP; SOCK_DGRAM表示的是数据报,对应的是UDP; SOCK_RAW对应的是原始套接字。
- protocal参数原本是用来指定通信协议的,但现在基本废除,因为前面两个参数已经可以实现了,一般写为0。
根据创建的socket类型不同,通信的方式也不同:
- AF_INET和SOCK_STREAM类型:实现TCP字节流通信。
- AF_INET和SOCK_DRGAM类型:实现UDP数据报通信。
实现本地进程间通信:本地字节流socket类型AF_LOCAL和SOCK_STREAM,本地数据报socket类型AF_LOCAL和SOCK_DGRAM。AF_UNIX与AF_LOCAL等价,所以AF_UNIX也属于本地socket。
接着,我们了解一下这三种通信的编程模式:
针对TCP协议通信的socket编程模型
- 服务端和客户端初始化socket,得到文件描述符
- 服务端调用bind,将绑定在IP地址和端口
- 服务端调用listen,进行监听
- 服务端调用accept,等待客户端连接
- 客户端调用connect,向服务器端的地址和端口发起连接请求
- 服务端accept返回用于传输的socket的文件描述符
- 客户端调用write写入数据,服务端read读取数据
- 客户端断开连接时会调用close,那么服务端read读取数据时就会读取到EOF,在处理完数据后,服务器端调用close表示关闭连接。
注意:监听socket和已完成连接socket不同,是两个socket。在成功建立连接后,通过write和read进行读写数据。具体连接过程请看计网专栏中的文章。
针对UDP协议通信的socket编程模型
- UDP不需要连接,所以不需要调用listen和connect,但UDP之间的交互需要IP地址和端口号,所以仍需要bind
- 对于UDP来说,不需要维护连接,也就没有所谓的客户端与服务端,只要有一个socket多台机器就可以任意通信
- 每次通信时,调用sendto和recvform都要传入目标主机的IP地址和端口
针对本地进程间通信的socket编程模型
- 本地socket的编程接口和IPv4、IPv6套接字接口是一致的,可以支持字节流和数据报两种协议。
- 本地socket的实现效率大大高于IPv4和IPv6的字节流、数据报socket实现。
- 对于本地字节流socket,其socket类型是AF_LOCAL和SOCK_STREAM。
- 对于本地数据报socket,其socket类型是AF_LOCAL和SOCK_DRGAM。
- 本地字节流socket和本地数据报socket在bind时,不需要像TCP和UDP一样绑定IP地址和端口,而是绑定一个本地文件。
二、线程通信
为什么需要线程?
有的进程需要同时做很多事,例如用QQ来进行聊天,发送文件等,而传统的进程只能串行执行一系列程序。因此,引入“线程”,来增加并发度。
线程间的通信目的主要是用于线程同步,所以线程没有像进程通信中的用于数据交换的通信机制。
锁机制
互斥锁、条件变量、读写锁和自旋锁。
互斥锁确保同一时间只能有一个线程访问共享资源。当锁被占用时试图对其加锁的线程都进入阻塞状态(释放CPU资源使其由运行状态进入等待状态)。当锁释放时哪个等待线程能获得该锁取决于内核的调度。
读写锁当以写模式加锁而处于写状态时任何试图加锁的线程(不论是读或写)都阻塞,当以读状态模式加锁而处于读状态时“读”线程不阻塞,“写”线程阻塞。读模式共享,写模式互斥。
条件变量可以以原子的方式阻塞进程,直到某个特定条件为真为止。对条件的测试是在互斥锁的保护下进行的。条件变量始终与互斥锁一起使用。
自旋锁上锁受阻时线程不阻塞而是在循环中轮询查看能否获得该锁,没有线程的切换因而没有切换开销,不过对CPU的霸占会导致CPU资源的浪费。 所以自旋锁适用于并行结构(多个处理器)或者适用于锁被持有时间短而不希望在线程切换产生开销的情况。
信号量机制(Semaphore)
包括无名线程信号量和命名线程信号量。线程的信号和进程的信号量类似,使用线程的信号量可以高效地完成基于线程的资源计数。信号量实际上是一个非负的整数计数器,用来实现对公共资源的控制。在公共资源增加的时候,信号量就增加;公共资源减少的时候,信号量就减少;只有当信号量的值大于0的时候,才能访问信号量所代表的公共资源。
信号机制(Signal)
类似进程间的信号处理。