linux同步与通信

时间:2022-09-06 21:56:49

这几天读完了UNP v2,对进程间通信与同步的方式有所了解,现对主要的知识点总结如下:

根据出现的历史,先有的管道,FIFO,信号,然后是systemV IPC,再是后来的Poxis IPC,systemV IPC是内核持续性的,而Poxis根据实现不同有的是内核有的是文件系统持续性。

管道:分为管道和FIFO, 管道一般用于父子进程,不能跨进程传输,通过pipe函数调用产生一对文件描述符,父进程fork子进程的时候会复制文件描述符,然后父子进程分别关闭自己不需要的读或写端,如此就可以通信,FIFO可以在文件系统上建立对象,属于一种文件类型(用p标识),可跨进程通信,但是当进程退出时,管道里无法保存数据,不同于管道,FIFO在文件系统上不会被删除。管道一般都是单工的,只能单向通信,管道通信的前提是管道的两端都被打开,不像消息队列等打开一端就可以发送消息。当管道的对端没有打开时,对管道读写都会阻塞, 当读端关闭时,写端写入数据会出发SIGPIPE信号。管道传输的也是字节流,必要时需要制定应用层传输协议来防治粘包。管道出现的较早,在linux系统中大量的使用了管道,例如popen函数的内部实现就是通过管道将调用的命令结果返回给popen调用方。管道的缓冲区大小有限,所以不适合大量的数据通信,当向管道中写入的数据大于缓冲区大小的时候,内核不保证写入的原子性。

消息队列:可以看做是一个消息链表,在某个进程往一个队列中写入消息之前,并不需要另外某个进程在该队列上等待消息的到达。具有内核持续性,也就说一个进程写入消息后可以中止,可让另一个进程在以后某个时候读出该消息。存放的是有边界的消息记录,每个消息含有优先级,POXIS消息队列可以进行notify,在消息到达后通过信号或线程进行消息通知。SystemV消息队列可以指定优先级,优先级高的被优先投递,并支持接收指定优先级的消息。在编写一对多模型的时候不同于管道需要创建多个连接,消息队列只需要创建一个连接,各个进程都可以从其中标识地取出属于自己的消息。缺点就是不能结合select / poll使用,必要时需要用管道转接。新编写的应用程序应该首先考虑使用POXIS消息队列。

互斥锁与条件变量: 可以实现生产者与消费者模型,可以动态分配也可以静态分配,当动态分配并指定共享时可以用在进程间同步,存在的问题是当拥有互斥锁的进程或线程异常终止时可能导致临界区数据不一致。当某个进程阻塞在一个条件变量上的时候,如果调用pthread_exit/pthread_cancel取消线程时,会使该线程再次获得该条件变量的互斥锁,并且线程退出时并不会释放该锁,这样就会造成死锁,解决的办法是使用pthread_cleanup_push/pthread_cleanup_pop来实现清理处理程序的安装和删除。

一般上面两种同步方式是推荐的同步方式,一般使用互斥锁的时候需要用RAII技法封装mutex的创建,销毁,加锁,解锁这四个操作,并用一个Guard对象的构造和析构函数进行lock和unlock操作,Guard是个栈上对象,对象的生命期刚好等于临界区,只使用非递归的mutex,且不使用跨进程的mutex。 使用条件变量时把判断布尔条件和wait()放到while循环中,因为可能会出现虚假唤醒spurious wakeup(参见这里), signal/brodcast操作需要放在mutex之外,否则其他线程无法获得锁,只会徒劳增加线程的调度,条件变量是非常底层的同步原语,很少直接使用,一般都是用它来实现高级的同步措施,如BlockingQueue<T>或CountDownLatch。

读写锁: 可以提高并发度,根据实现的不同,可以分为读优先,写优先。读写锁默认也是线程间的锁,如果需要在进程间锁也需要指定PTHREAD_PROCESS_SHARED 属性。经验告诉我们尽量不要使用读写锁,因为从性能上来说,读写锁并不见得比普通mutex更高效,无论如何reader lock加锁的开销不会比mutex lock小,因为它要更新当前reader的数目,如果临界区很小,锁竞争不激烈,那么mutex往往会更快,并且通常reader lock是可重入的,writer lock是不可重入的,为了防止writer lock饥饿通常会阻塞后来的reader lock,在一些追求低延迟读的场合不适合用读写锁。

记录锁: 可以提供细粒度的锁,并且程序终止时会完成已有锁的清理工作。由内核维护记录锁,分为建议性锁和强制性锁,强制性锁可以阻止非协作进程读一个已被锁住的文件,但这并不能保证数据的一致性。

信号量: 分为有名信号量,基于内存的信号量,systemV信号量,Poxis有名信号量通过一个文件名打开,标识一个内核中文件系统的结构,所以是随内核持久的,可以在进程间或线程间使用,而基于内存的信号量则具有随进程的持续性。

相对于互斥锁有三点不同:

1. 互斥锁必须由给他上锁的线程解锁,信号量没有这种限制;

2. 信号量有一个与之关联的值,它由挂出操作加一,由等待操作减一,那么任何线程都可以挂出一个信号,即使当时没有线程在等待该信号量的值为正数也没关系,但是如果某个线程调用pthread_cond_signal,但是没有任何线程阻塞在pthread_cond_wait调用中,那么相应条件变量的信号将丢失。

3. 各种同步技巧中能够从信号处理程序中安全调用的唯一函数是sem_post函数。互斥锁是为上锁而优化的,条件变量是为等待而优化的,信号量即可用于上锁又可用于等待,因而可能导致更多的开销和更高的复杂性。

SystemV信号量则是计数信号量集,他维护一组信号量,他允许增长或减少信号量的值不只是1,所以相对posix信号量较为复杂。

共享内存区:共享内存区是IPC形式中最快的,一旦这样的内存区映射到共享他的进程的地址空间,这些进程间数据的传递就不需要涉及内核(指不需要通过执行任何进入内核的系统调用,共享内存本身还是在内核中),然而一般需要某种形式的同步。而管道,FIFO,消息队列都需要经由内核传递,所以数据需要四次复制,且都是内核和用户态之间的。

linux同步与通信

还有一种进程间通信的方式就是socket,其最大的好处就是可以跨主机,具有伸缩性,TCP port由一个进程独占,且操作系统会自动回收,即使程序意外退出,也不会给操作系统留下垃圾,程序重启之后就能比较容易地恢复,而不需要重启操作系统(用跨进程的mutex就有这个风险),对于一些高可用性的系统,快速的故障恢复就导致进程立即重启,这就要求程序只是用操作系统能自动回收的IPC,不使用生命期大于进程的IPC,也不是用无法重建的IPC。还有一个好处,既然port是独占的,那么可以防止程序重复启动,后面那个进程抢不到port,自然就没法初始化了。两个进程通过TCP通信,如果一个崩溃了,操作系统会关闭连接,另一个进程几乎能立刻感知,可以快速faliover。并且TCP协议的一个天生好处就是“可记录,可重现”,用tcpdump等工具就可以调试。并且可以跨语言通信。

muduo库作者陈硕在《Linux多线程服务端编程》中指出“不要使用跨进程的mutex或semaphore”,也不要使用共享内存,因为进程意外终止的话,无法清理资源,特别是无法解锁。另外也不要使用父子进程共享文件描述符的方式来通信(pipe),父进程死了,子进程怎么办?pipe是无法重建的"。

所有的共享方式都各有利弊吧,例如基于内核的有的需要内存拷贝,共享内存快却需要加锁来保障数据的一致性,所以我们应该按需使用,此处不再赘述。