***基本原理***:
通常情况下,程序只能访问自身的数据,和其它进程没有沟通,每个进程都是一个单独存在的个体,进程之间不需要协作就可以完成自身的任务了。但随着需要解决问题复杂性的增加,一个进程不可能完成所有的工作,必须由多个进程之间互相配合才能更快、更好、更强的解决问题,如同人与人之间的协作可以做出更大的事情一样。
但是,处于安全性的考虑,OS会限制进程只能访问自身的数据,不能把“手”伸到其它进程的内部,这怎么办呢?进程间的沟通问题怎么解决呢?
有问题,就需要沟通;需要沟通,就需要有沟通的媒介;为了公平和公正,沟通的媒介的控制权不应该属于沟通的任何一方。以此推论下去,在计算机系统中,承担沟通媒介控制任务的就只有OS自身了。
所以,进程间通信的基本原理就是:OS提供了沟通的媒介供进程之间“对话”用。既然要沟通,如同人类社会的沟通一样,沟通要付出时间和金钱,计算机中也一样,必然有沟通需要付出的成本。出于所解决问题的特性,OS提供了多种沟通的方式,每种方式的沟通成本也不尽相同,使用成本和沟通效率也有所不同。我们经常听到的 管道、消息队列、共享内存都是OS提供的供进程之间对话的方式。
既然是沟通,必然是沟通双方有秩序的说话,否则就成吵架了,谁也听不到对方说什么。如同法庭中法官控制控辩双方的发言时机和发言时间一样,OS也必须提供此类的管制方式使得进程的沟通显的有序和谐。我们经常听到的 互斥锁、条件变量、记录锁、文件锁、信号灯均属此列。
沟通的媒介是什么?
上面我们提到管道、消息队列、共享内存都是OS所提供的对话的方式,进程所说出去的“话”至少需要暂时保存在某个地方,然后才能被别的进程取走(听到)。不同的实现对话方式,保存中间信息的媒介从逻辑上分有文件系统,内核和内存三个部分。其实保存在内核中也是保存在内存中的,只是这部分内存只能OS自己访问,普通进程不能直接读写。
***通信方式***:
经常使用到的进程间通信有:管道、消息队列和共享内存。
>>>管道
最开始出现的管道是用于具有血缘关系的进程之间共享数据的。父进程首先创建一个管道,然后再fork()出子进程,子进程自动共享对管道的访问权限。这种管道是没有名字的(只有进程中的一个标识符进行标识),因此也称为匿名管道。
如我们在shell中运行 ls * | grep foo 时,shell就会创建一个匿名管道,并把ls的stdout重定向到该管道的输入端,把grep的stdin重定向到管道的输出端,ls 的输出就自动的称为grep的输入。这一切对 ls 和 grep 都是透明的,它们并不知道管道的存在。
后来就出现了FIFO(first in first out),也称为有名管道。有名和无名是针对OS来说的,OS能直接管理到就是有名的,否则就是无名的。FIFO出现后,管道就不仅仅用于具有血缘关系进程的通信了,而是可以用于任意进程之间的通信。
管道的生命周期是跟随进程的。当所有使用管道的进程退出或者所有进程都显示的调用close()后,管道就会被丢弃掉(其实进程退出等效于调用close(),因为进程退出时所有打开的资源标识符都会被OS关掉)。如果这时候管道中仍然有未读取的数据,这些数据同样被丢弃掉。而且在往管道中写入数据时,必须存在读取数据的进程,不然就没有任何意义了。
>>>消息队列
消息队列的作用与管道类似,有足够写权限的进程可往消息队列中放置消息,有足够读权限的的进程可以从消息队列中读取消息。与管道不同的是在某个进程写入消息之前,并不需要另外某个进程在该队列上等待消息的到达。这是因为消息队列的声明周期是跟随内核的。只要内核不删除消息队列,即使进程退出了,消息队列依然是存在的。(不过在现有的OS的实现中,消息队列是通过引用计数的方式记录当前有多少个进程已经打开了自己,所有进程退出时,引用计数为0时,此时OS也会自动的删除消息队列)。
>>>共享内存
共享内存也有两种:匿名共享内存和有名共享内存。它们的区别和上面提到的管道和FIFO一样,匿名共享内存只能用于具有血缘关系的进程间通信,有名共享内存则没有此限制。
共享内存的声明周期也是跟随内核的。与上述两种方式最大的不同在于,使用共享内存沟通的进程之间进行数据交换是不经过系统调用进行进程--内核的数据copy过程的,所有的数据交换都是在内存中完成的。相比于前两种方式,共享内存的方式是速度最快的。
***同步方式***:
为了能够有效的控制多个进程之间的沟通过程,保证沟通过程的有序和和谐,OS必须提供一定的同步机制保证进程之间不会自说自话而是有效的协同工作。比如在共享内存的通信方式中,两个或者多个进程都要对共享的内存进行数据写入,那么怎么才能保证一个进程在写入的过程中不被其它的进程打断,保证数据的完整性呢?又怎么保证读取进程在读取数据的过程中数据不会变动,保证读取出的数据是完整有效的呢?
常用的同步方式有: 互斥锁、条件变量、读写锁、记录锁(文件锁)和信号灯。
>>>互斥锁
顾名思义,锁是用来锁住某种东西的,锁住之后只有有钥匙的人才能对锁住的东西拥有控制权(把锁砸了,把东西偷走的小偷不在我们的讨论范围了)。所谓互斥,从字面上理解就是互相排斥。因此互斥锁从字面上理解就是一点进程拥有了这个锁,它将排斥其它所有的进程访问被锁住的东西,其它的进程如果需要锁就只能等待,等待拥有锁的进程把锁打开后才能继续运行。
在实现中,锁并不是与某个具体的变量进行关联,它本身是一个独立的对象。进(线)程在有需要的时候获得此对象,用完不需要时就释放掉。
互斥锁的主要特点是互斥锁的释放必须由上锁的进(线)程释放,如果拥有锁的进(线)程不释放,那么其它的进(线)程永远也没有机会获得所需要的互斥锁。互斥锁主要用于线程之间的同步。
条件变量
上文中提到,对于互斥锁而言,如果拥有锁的进(线)程不释放锁,其它进(线)程永远没机会获得锁,也就永远没有机会继续执行后续的逻辑。在实际环境下,一个线程A需要改变一个共享变量X的值,为了保证在修改的过程中X不会被其它的线程修改,线程A必须首先获得对X的锁。现在假如A已经获得锁了,由于业务逻辑的需要,只有当X的值小于0时,线程A才能执行后续的逻辑,于是线程A必须把互斥锁释放掉,然后继续“忙等”。如下面的伪代码所示:
// get x lock
while(x <= 0){
// unlock x ;
// wait some time
// get x lock
}
// unlock x
这种方式是比较消耗系统的资源的,因为进程必须不停的主动获得锁、检查X条件、释放锁、再获得锁、再检查、再释放,一直到满足运行的条件的时候才可以。因此我们需要另外一种不同的同步方式,当线程X发现被锁定的变量不满足条件时会自动的释放锁并把自身置于等待状态,让出CPU的控制权给其它线程。其它线程此时就有机会去修改X的值,当修改完成后再通知那些由于条件不满足而陷入等待状态的线程。这是一种通知模型的同步方式,大大的节省了CPU的计算资源,减少了线程之间的竞争,而且提高了线程之间的系统工作的效率。这种同步方式就是条件变量。
坦率的说,从字面意思上来将,“条件变量”这四个字是不太容易理解的。我们可以把“条件变量”看做是一个对象,一个铃铛,一个会响的铃铛。当一个线程在获得互斥锁之后,由于被锁定的变量不满足继续运行的条件时,该线程就释放互斥锁并把自己挂到这个“铃铛”上。其它的线程在修改完变量后,它就摇摇“铃铛”,告诉那些挂着的线程:“你们等待的东西已经变化了,都醒醒看看现在的它是否满足你们的要求。”于是那些挂着的线程就知道自己醒来看自己是否能继续跑下去了。
>>>读写锁
互斥锁是排他性锁,条件变量出现后和互斥锁配合工作能够有效的节省系统资源并提高线程之间的协同工作效率。互斥锁的目的是为了独占,条件变量的目的是为了等待和通知。但是现实世界是很复杂di,我们要解决的问题也是多种多样di.从功能上来说,互斥锁和条件变量能够解决基本上所有的问题,但是性能上就不一定完全满足了。人的无休止的欲望促使人发明出针对性更强、性能更好的同步机制来。读写锁就是这么一个玩意儿。
考虑一个文件有多个进程要读取其中的内容,但只有1个进程有写的需求。我们知道读文件的内容不会改变文件的内容,这样即使多个进程同时读相同的文件也没什么问题,大家都能和谐共存。当写进程需要写数据时,为了保证数据的一致性,所有读的进程就都不能读数据了,否则很可能出现读出去的数据一半是旧的,一半是新的状况,逻辑就乱掉了。
为了防止读数据的时候被写入新的数据,读进程必须对文件加上锁。现在假如我们有2个进程都同时读,如果我们使用上面的互斥锁和条件变量,当其中一个进程在读取数据的时候,另一个进程只能等待,因为它得不到锁。从性能上考虑,等待进程所花费的时间是完全的浪费,因为这个进程完全可以读文件内容而不会影响第一个,但是这个进程没有锁,所以它什么也做不了,只能等,等到花儿都谢了。
所以呢,我们需要一种其它类型的同步方式来满足上面的需求,这就是读写锁。
读写锁的出现能够有效的解决多进程并行读的问题。每一个需要读取的进程都申请读锁,这样大家互不干扰。当有进程需要写如数据时,首先申请写锁。如果在申请时发现有读(或者写)锁存在,则该写进程必须等待,一直等到所有的读(写)锁完全释放为止。读进程在读取之前首先申请读锁,如果所读数据被写锁锁定,则该读进程也必须等待读锁被释放位置。
很自然的,多个读锁是可以共存的,但写锁是完全互相排斥的。
>>>记录锁(文件锁)
为了增加并行性,我们可以在读写锁的基础上进一步细分被锁对象的粒度。比如一个文件中,读进程可能需要读取该文件的前1k个字节,写进程需要写该文件的最后1k个字节。我们可以对前1k个字节上读锁,对最后1k个自己上写锁,这样两个进程就可并发工作了。记录锁中的所谓“记录”其实是“内容”的概念。使用读写锁可以锁定一部分,而不是整个文件。
文件锁可以认为是记录锁的一个特例,当使用记录锁锁定文件的所有内容时,此时的记录锁就可以称为文件锁了。
>>>信号灯
信号灯可以说是条件变量的升级版。条件变量相当于铃铛,铃铛响后每个挂起的进程还需要自己获得互斥锁并判断所需条件是否满足,信号灯把这两步操作糅合到一起。
在Posix.1基本原理一文声称,有了互斥锁和条件变量还提供信号灯的原因是:“本标准提供信号灯的而主要目的是提供一种进程间同步的方式;这些进程可能共享也可能不共享内存区。互斥锁和条件变量是作为线程间的同步机制说明的;这些线程总是共享(某个)内存区。这两者都是已广泛使用了多年的同步方式。每组原语都特别适合于特定的问题”。尽管信号灯的意图在于进程间同步,互斥锁和条件变量的意图在于线程间同步,但是信号灯也可用于线程间,互斥锁和条件变量也可用于进程见。应当根据实际的情况进行决定。
信号灯最有用的场景是用以指明可用资源的数量。比如含有10个元素的数组,我们可以创建一个信号灯,初始值为0.每当有进程需要读数组中元素时(假设每次仅能读取1个元素),就申请使用该信号灯(信号灯的值减1),当有进程需要写元素时,就申请挂出该信号等(信号灯值加1)。这样信号灯起到了可用资源数量的作用。如果我们限定信号灯的值只能取0和1,就和互斥锁的含义很相同了。