进程管理(三)——进程间通信和多线程问题 前言 不同的进程之间的地址空间是相互隔离的,所以相互通信需要操作系统的支持。 多个进程进行通信和多个线程共享资源时,都需要考虑共享资源的同步问题。 进程间通信 IPC,Inter Process Communication,每个进程的用户地址空间都是独立的,一般而言是不能互相访问的,但内核空间是每个进程都共享的,所以进程之间要通信必须通过内核(除共享内存外)。 管道 所谓的管道,就是内核里面的一串缓存(在内核空间中维护的缓冲器)。管道有两个端(描述符),分别是写端和读端,从管道的一段写入的数据,实际上是缓存在内核中的,另一端读取,也就是从内核中读取这段数据。管道传输的数据是无格式的字节流且大小有限。 管道传输数据是单向的(半双工),如果想相互通信,我们需要创建两个管道才行。 管道通信数据遵循先入先出原则。匿名管道(pipe):「」表示的管道称为匿名管道,没有文件实体,只存在于内核空间中。只能用于具有亲缘关系的父子进程间或者兄弟进程之间的通信。命名管道(FIFO):命名管道是以特殊文件形式(p 类型文件)存在于文件系统中的,可以按照操作文件的方式对命名管道进行操作。在使用命名管道前,先需要通过 命令来创建,并且指定管道名字。可以在不相关的进程间相互通信。 从匿名管道读写数据是一次性操作,数据一旦被读走,它就从匿名管道中被抛弃,释放空间以便写更多的数据。 在管道中没有数据的情况下,对管道的读操作会阻塞,直到管道内有数据为止。大于管道长度的写操作会阻塞,直到当前管道中的数据被读取为止。 管道通信效率低,不适合进程间频繁地交换数据。 管道是进程的一种资源。 为什么匿名管道只能用于具有亲缘关系的父子进程间或者兄弟进程之间的通信? 因为管道是匿名的,只能由父进程通过 创建子进程,使子进程复制父进程的文件描述符,从而创建出两个匿名管道分别用于收发数据。 在 shell 里面执行 命令的时候,A 进程和 B 进程都是 shell 创建出来的子进程。
消息队列 Message Queue,消息队列是保存在内核中的消息链表,在发送数据时,会分成一个一个独立的数据单,也就是消息体(数据块),消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型,所以每个消息体都是固定大小的存储块。如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。 如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在。 消息队列有一条消息的最大长度和一个队列的最大长度限制。 消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销。 共享内存 Shared memory,使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据的更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等。 共享内存的通信方式不需要内核的参与。通过 shmget(Shared Memory GET)来分配一个共享内存。要让一个进程对一块共享内存的访问,这个进程必须先调用 shmat(SHared Memory Attach),绑定到共享内存。 信号量 Semaphores,信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步。 信号量表示资源的数量,控制信号量的方式有两种原子操作:P 操作,将信号量减 1,相减后如果信号量 < 0,则表明资源已被占用,进程需阻塞等待;否则可正常继续执行。V 操作,将信号量加 1,相加后如果信号量 <= 0,则表明当前有阻塞中的进程;否则表示不会阻塞。 P 操作是用在进入临界区之前,V 操作是用在离开临界区之后,这两个操作是必须成对出现的。 初始化:信号初始化为 ,就代表是互斥信号量,它可以保证共享内存在任何时刻只有一个进程在访问,这就很好的保护了共享内存。信号初始化为 ,就代表是同步信号量,它可以保证进程 A 应在进程 B 之前执行。信号量只能由 PV 操作,PV 的原子性由操作系统保证。P 相当锁,可能会阻塞线程,而 V 相当于释放锁,不会阻塞线程。 信号 Signal,信号是进程间通信机制中唯一的异步通信机制,可以在任何时候发送信号给某一进程,用于通知接收进程某个事件的发生。 当信号发送到某个进程中时,操作系统会中断该进程的正常流程,并进入相应的信号处理函数执行操作,完成后再回到中断的地方继续执行。(是一种软中断) 如果目标进程先前注册了某个信号的处理程序(signal handler),则此处理程序会被调用,否则缺省的处理程序被调用。 效率很高但是不能传输任何要交换的数据。 Socket 管道、消息队列、共享内存、信号量和信号都是在同一台主机上进行进程间通信,那要想跨网络与不同主机上的进程之间通信,就需要 Socket 通信了。(也可以在同主机上通信) socket 套接字是支持 TCP/IP 的网络通信的基本操作单,可以看做是不同主机之间的进程进行双向通信的端点,简单的说就是通信的两方的一种约定,用套接字中的相关函数来完成通信过程。 socket 有三个属性,分别代表:domain 参数用来指定协议族,比如 AF_INET 用于 IPV4、AF_INET6 用于 IPV6、AF_LOCAL/AF_UNIX 用于本机;type 参数用来指定通信特性,比如 SOCK_STREAM 表示的是字节流,对应 TCP、SOCK_DGRAM 表示的是数据报,对应 UDP、SOCK_RAW 表示的是原始套接字;protocal 参数原本是用来指定通信协议的,但现在基本废弃。因为协议已经通过前面两个参数指定完成,protocol 目前一般写成 0 即可; 根据创建 socket 类型的不同,有三种不同的通信方式:TCP 字节流通信、UDP 数据报通信、本地进程间通信。 TCP 字节流通信
服务端和客户端初始化 ,得到文件描述符;服务端调用 ,将绑定在 IP 地址和端口;服务端调用 ,进行监听;服务端调用 ,等待客户端连接;客户端调用 ,向服务器端的地址和端口发起连接请求;服务端 返回用于传输的 的文件描述符;客户端调用 写入数据;服务端调用 读取数据;客户端断开连接时,会调用 ,那么服务端 读取数据的时候,就会读取到了 ,待处理完数据后,服务端调用 ,表示连接关闭。 这里需要注意的是,服务端调用 时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据。 所以,监听的 socket 和真正用来传送数据的 socket,是「两个」 socket,一个叫作监听 socket,一个叫作已完成连接 socket。 成功连接建立之后,双方开始通过 read 和 write 函数来读写数据,就像往一个文件流里面写东西一样。 UDP 数据报通信
UDP 是没有连接的,所以不需要三次握手,也就不需要像 TCP 调用 listen 和 connect,但是 UDP 的交互仍然需要 IP 地址和端口号,因此也需要 bind。 对于 UDP 来说,不需要要维护连接,那么也就没有所谓的发送方和接收方,甚至都不存在客户端和服务端的概念,只要有一个 socket 多台机器就可以任意通信,因此每一个 UDP 的 socket 都需要 bind。 另外,每次通信时,调用 sendto 和 recvfrom,都要传入目标主机的 IP 地址和端口。 本地进程间通信 本地 socket 被用于在同一台主机上进程间通信的场景:本地 socket 的编程接口和 IPv4 、IPv6 套接字编程接口是一致的,可以支持「字节流」和「数据报」两种协议;本地 socket 的实现效率大大高于 IPv4 和 IPv6 的字节流、数据报 socket 实现; 对于本地字节流 socket,其 socket 类型是 AF_LOCAL 和 SOCK_STREAM。 对于本地数据报 socket,其 socket 类型是 AF_LOCAL 和 SOCK_DGRAM。 本地字节流 socket 和 本地数据报 socket 在 bind 的时候,不像 TCP 和 UDP 要绑定 IP 地址和端口,而是绑定一个本地文件,这也就是它们之间的最大区别。 多线程问题 当然一个程序可以有多个执行流程,也就是所谓的多线程程序。多个线程之间可以共享数据,那么就存在共享数据同步问题。 举个例子吧,比如最常见的 i=i+1 这个操作,其实在 CPU 执行起来是三条指令:从内存中取出 i 的值,放入寄存器中;对寄存器中的值进行加一操作;把运算完的值放回到内存中去,即给 i 变量重新赋值。 那假设在一个 2 核的计算机中有两个线程(在同一个进程中),一个核分别运行一个线程对同一个变量进行 i=i+1 这个操作,假设这两个线程是并行的,那就会出现同时从内存中取出 i 的值(假设为 100),同时进行加一操作得到 101,再同时将 101 写入内存,最后变量 i 的值为 101,但是在我们的视角里是进行了两次加一操作,应该是 102 才对。 这种情况就称为竞争条件(Race Condition):当多线程相互竞争操作共享变量时,在执行过程中发生了上下文切换时,可能会得到错误的结果。 多线程执行操作共享变量可能会导致竞争状态的代码段称为临界区(Critical Section)。 互斥 解决竞争的常见办法是互斥(Mutual Exclusion),即保证一个线程在临界区执行时,其他线程应该被阻止进入临界区。 同步 并发线程在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通信息称为线程同步。 实现同步和互斥,常见的有三种方法:锁、信号量和管程。锁可以保证共享资源在同一时刻只能被一个线程访问。信号量允许同一时刻多个线程访问同一资源,但是需要控制同一时刻访问此资源的最大线程数量。管程和信号量类似,是编程语言的一次封装。 锁 自旋锁和互斥锁是最基本的两种锁方式,可以在这两者的基础上实现更高级的锁:读写锁、乐观锁和悲观锁等。 自旋锁 Spin Lock,当临界区被其他线程锁时,当前线程会一直循环等待,不做其他任何事情。 自旋锁是通过 CPU 提供的 CAS 函数实现的。 在单核 CPU 上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则无法使用,因为一个自旋的线程永远不会放弃 CPU。 互斥锁 当没到锁的时候,就把当前线程放入到锁的等待队列,然后执行调度程序,把 CPU 让给其他线程执行。 读写锁 读写锁由读锁和写锁组成,其工作原理是:当写锁没有被线程持有时,多个线程能够并发地持有读锁;一旦写锁被线程持有后,读线程的读锁的操作会被阻塞,而且其他写线程的写锁的操作也会被阻塞。 即读锁是共享锁,可以被多个线程同时持有;而写锁是独占锁,任何时刻只能有一个线程持有写锁。 根据实现的不同,读写锁可以分为以下三类。读优先锁:当一个进程持有读锁,其他写进程写锁的操作都会被阻塞,但是其他读进程读锁的操作可以正常进行,直到所有读进程都释放了读锁。写优先锁:当一个进程持有读锁,其他读写操作都会被阻塞,且当读锁被释放后先执行写操作。公平读写锁:用队列把锁的线程排队,不管是写线程还是读线程都按照先进先出的原则加锁。 乐观锁悲观锁 悲观锁认为多线程修改资源的概率较高,容易出现冲突,所以访问资源前需要上锁。乐观锁认为出现冲突的概率较低,所以先允许修改资源,再验证有没有发生冲突,如果发生了冲突,交由用户自行解决。 互斥锁、自旋锁、读写锁,都是属于悲观锁。 常见的乐观锁有:在线编辑文档(常采用直接覆盖的方式解决冲突)、git svn 等版本管理工具(常采用让用户自行解决冲突的办法,用户可以取消提交,也可以覆盖提交) CAS CAS(Compare And Swap)是一种有名的无锁(lock-free)算法。也是一种现代 CPU 广泛支持的 CPU 指令级操作,只有一步原子操作,所以非常快。而且 CAS 避免了请求操作系统来裁定锁的问题,不用麻烦操作系统,直接在 CPU 内部就搞定了。 CAS 有三个操作参数:内存位置 V(它的值是我们想要去更新的)预期原值 A(上一次从内存中读取的值)新值 B(应该写入的新值) CAS 的操作过程:将内存位置 V 的值与 A 比较(compare),如果相等,则说明没有其它线程来修改过这个值,所以把内存 V 的的值更新成 B(swap);如果不相等,说明 V 上的值被修改过了,不更新,而是返回当前 V 的值,再重新执行一次任务再继续这个过程。 所以,当多个线程尝试使用 CAS 同时更新同一个变量时,其中一个线程会成功更新变量的值,剩下的会失败。失败的线程可以重试或者什么也不做。 信号量 信号量的概念在上面的进程间通信中已经介绍过了,也能够用于解决多线程的同步和互斥问题。 信号量使用场景 互斥:信号量的初始值设为 1。同步:信号量的初始值设为 0。生产者-消费者问题:设置阻塞队列和一个二进制信号量,任何时刻只能有一个生产者线程或消费都线程访问缓冲区。设置两个资源信号量,初始值分别为 0 和缓冲区长度,当缓冲区满时,生产者线程必须等待,反之消费者线程必须等待。 管程 Monitor,管理共享变量以及对共享变量的操作过程,让他们支持并发。 管程相比信号量来说,其隐蔽了同步的细节,更易于用户维护,是编程语言对信号量的一次封装。 总结 每个进程的用户地址空间都是独立的,一般而言是不能互相访问的,但内核空间是每个进程都共享的,所以进程之间要通信必须通过内核(除共享内存外)。 常见的进程间通信方式有六种:管道、信号、信号量、共享内存、消息队列、Socket。 当共享资源时(既可以读也可以写),就会存在同步问题,所以进程间通信和多线程共享资源都需要设置一定的机制来进行保证。 实现同步和互斥通常有三种办法:锁、信号量和管程。
2024最新激活全家桶教程,稳定运行到2099年,请移步至置顶文章:https://sigusoft.com/99576.html
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请联系我们举报,一经查实,本站将立刻删除。 文章由激活谷谷主-小谷整理,转载请注明出处:https://sigusoft.com/20398.html