1、进程同步与线程同步
1.1、进程与线程
我只讨论linux下进程与线程的区别:
进程在内核中由一个进程描述符(PCB)表示,内核中的数据结构就是 task_struct ,这个task_struct内部保存了整个进程在执行过程中所有需要的信息,其中有几个下文会用到的域说明下:
- mm (mm_struct) : 这个域内部表示了整个进程的地址空间,在linux内部通过VMA表示进程中不同的内存区域,不同的进程间mm指向的内存区域不同,在linux中线程与进程最大的区别就在于其mm区域指向的进程地址空间是否是同一个内存区域。在Linux中线程的概念是比较弱,线程表现的更像是“轻量级进程”,因为在Linux中一个线程也是通过一个PCB进行描述的,但是很重要的一点就是父子线程的mm是指向同样的内存区域的(其实更准确的说它们的页表相同)。进程更多的是资源总和的描述,而线程则是作业调度的描述,一个进程最少含有一个线程,在Linux中一个进程就于一个线程,这点与window就很不一样。最后需要说明一点,以为在Linux中进程与线程都是使用PCB进行描述,所以它们的调度是一样的。很多基于内存的同步技术都需要对这个域内部运行有一定了解。更多相关内容可以参考《连接装载与库》、《深入Linux内核架构》、《深入理解Linux虚拟内存管理》、《Linux内核设计的艺术》四本书。
- files (files_struct) :这个域内部保存了整个进程打开的所有文件,这个数据结构还属于比较高层的数据结构,真正重要的是需要理解Linux中关于虚拟文件系统部分的内容,了解在linux中如何使用通用节点结构inode表示所有文件信息。关于文件系统的内容也相当复杂,尤其是还存在符号链接以及硬链接的情况下是整个问题更加复杂。不过需要时刻警惕的一点就是,在内存中对于一个文件inode数据结构只有一个(符号链接会让两个inode指向同一个磁盘文件,但是文件链接和符号链接在内存中的inode还是只有一个),每个进程打开的文件是用file数据结构进行表述的,file数据结构会与inode进行映射,也就是说,两个进程分别打开了同一个文件,每个进程会有各自的file数据结构,但是这两个file会指向同一个inode。因为这种共享,所以产生了很多同步问题,而且很多同步技术也是利用了这些特性进行实现的。更多相关的内容可以参考《深入Linux内核架构》《存储技术原理分析》两本书。
1117 struct task_struct {
1171 .....
1172 struct mm_struct *mm, *active_mm;
......
1270 /* open file information */
1271 struct files_struct *files;
1.2、进程同步问题
1.2.1、进程的产生
在Linux中,将创建子进程与加载可执行程序是分成了两个部分,分别是fork和exec。进程A调用fork系统调用就可以产生一个子进程B,在内核中的表现就是创建了一个新的PCB。如果需要加载新的可执行文件,那就继续调用exec系统调用。这个过程说起来简单,其实过程比较复杂。
我们关注其中的mm、files域,我们可以认为父子进程间这两个域已经不是指向同一块内存了(注:其实因为有COW技术,所以这里说不是同一个内存是很不准确的,要说明白最好得把页表这点的问题全部弄明白),这意味着,进程B通过继承获取到的进程A中的变量,虽然两个同名而且获取到了相同的值,但是已经指向了不同的内存,所以它们已经在操作不同的内存了。
那files域会怎么样?之前说过,在整个内存中同一个文件只有同一个inode,但是可以由多个file数据结构指向相同的inode。进程B从进程A中继承得到的files域确实指向的不同的内存,也就是说,进程A和进程B间同一个文件的file数据结构其实是在两个不同的内存中,但是需要清晰的知道,这两个file数据结构最后还是指向了同一个inode数据结构。同理,即使在两个没有血缘关系的进程中,打开同一个文件,它们最后指向的inode数据结构还是同一个。
如果调用了exec,则内核则会为这个进程的PCB加载可执行文件(注:程序和进程是两个完全不同的概念,前者是静态概念,指一个可以被解释的指令集合;后者则是一个动态概念,它是对整个资源进行了抽象,最后进程中的资源如何进行管理则是线程的概念范畴了),在加载可执行文件后,从父进程继承得到的mm域就完全被清空,这意味着不仅仅mm域指向了两个不同的内存域,甚至两者所见到进程地址空间都不再一样了。同理,files域也是如此。更准确一点说的话就是,两个父子进程间,除了它们的父子关系还保持着,其它的已经完全不一样了。当然,我们甚至还可以将这一层父子关系打断,让子进程变成守护进程,不过这就是另外一个话题了。
1.2.2、进程同步问题
经过上面的介绍,进程同步问题就呼之欲出了:
- 同步问题1:多个不同的进程(无论是否有血缘关系),因为它们的mm域是相互独立的,那如何让它们相互之间进行通信(也就是互相知道对方内部的数据)
- 同步问题2:多个不同的进程间传递数据,如果只有两个进程,一个读出一个写入,那不会出现问题,问题在于如果存在多个进程读出/写入的情况,那由那个进程进行操作就成了问题,所以多进程间通信的时候也需要解决同步问题。
- 同步问题2:多个不同的进程打开了一个同一个文件,那多个文件在向文件中写入数据的时候,因为最后都是对同一个inode进行操作,这样就会出现竞争,此时应该如何确保写入数据不会出现同步问题(毕竟无法保证一个进程在写入过程中不会被其他进程打断)
1.2.2.1、进程通信问题
现在先来看看问题1的一些解决方法,如果认真观察,会发现这些方法中总会出现文件这个东西:
- 方法1、使用套接字:非常重量级的一个方法,但是可以解决几乎所有的进程间通信问题(同步问题不好说),无论通信的进程是在同一个主机上还是两个不同的主机上,甚至是否是父子进程,都可以使用套接字进行解决。但是需要注意的是,套接字在使用上也存在很多局限性,例如,UDP不具有同步机制,也就是说它不能保证传递数据的顺序,甚至它无法保证通信数据的可靠性,它不提供任何错误恢复机制。TCP可以保证数据传输的可靠性,也保证数据流是有序的,但它无法实现多个进程间的通信,因为TCP只提供了单播机制,并不提供多播和广播。
- 方法2、管道与FIFO:管道(pipe)用于解决父子进程间的通信问题,但是它并不能用于解决无血缘关系下的进程间通信问题,主要和它没有名字有关,另一个需要注意的就是pipe方法提供的是一个半双工的管道,所以如果需要全双工管道就要创建两个pipe了。FIFO就是有名管道,因为它具有名字,所以两个没有血缘关系的进程可以使用FIFO进行通信,只是因为其具有名字,所以在用起来会比pipe繁琐一些,尤其最后需要使用unlink将不要的FIFO从系统中删除。管道与FIFO底层的机制其实都是利用了文件的特性,因为inode只有一个,两个不同的进程可以共享一个inode,所以书上说的复杂,如果最后将这些都看做是文件,一切都很清楚了。管道与FIFO内部带有同步机制,意思就是每次只能有一个进程读或者管道中的数据,所以用的时候就不用担心多消费者和多生产者的竞争问题了。另外一个需要注意的就是,FIFO的在打开的时候O_NOBLOCK标识的微妙影响,更详细内容可以查看UNP的图4-21。
- 方法3、共享内存(内存映射):这种方法也需要借助文件的特性,利用文件作为中间桥梁,先简要介绍下inode中的地址空间(address_space)的概念,一个文件是具有长度大小的,平时操作的时候都是通过文件偏移量在指明当前的读写位置,但是这个空间是对一个文件而言的,对于系统内核其实是看不到的,那如何将这个文件的空间映射到系统内存中,中间就是依靠address_space这个数据结构进行实现的,address_space内部是一棵基数树,实现了文件地址空间和物理内存之间的映射,请注意,这里映射的是物理内存和文件地址,还没有实现进程地址空间和物理内存的映射。通过mmap调用将文件中的地址空间映射到进程地址空间中的某个区域中。这样两个进程只要打开了同一个文件,然后将这个文件中相同的地址空间映射到各自的内存区域中,这样虽然是访问两个不同的进程地址空间(这个说法有些含糊,因为也可以做成相同的),但是最后通过页表最后访问到的物理内存是一致的。所以两个进程也可以相互通信了。内存映射用的非常多,最典型的一个就是动态库的实现,一个动态库就是一个文件,在物理内存中只有一个副本,不同进程就是通过内存映射将进程地址空间与文件的空间进行映射,所以,我们平时看到我们虽然打开了很多程序,其实耗费的内存并不大,就因为这些动态库只在内存中含有一个副本的原因了。
- 方法4、使用消息队列:(标记一下,这部分内容还没怎么看,有些不明白,有空了补充)
接下来考察一下这几种方法见的效率问题:最快的必然是使用内存映射的方法了,多个进程访问同一块物理内存时中间就隔着一层页表,这个可以算作是在内核中最快的访问距离了,当然,在读写过程中一定要保证操作不能。其次是使用pipe、FIFO以及消息队列这种通信方式,这些通信方式中间都依靠了文件这个对象进行数据的传输,也就是说如果进程A要将数据传递给进程B,则这个数据首先要充进程A的地址空间复制一个副本到达内核空间(更准确的是进入了打开的文件inode中的address_space中管理的缓冲区中),然后进程B如果还需要读取数据,则需要将这个数据从内核空间中复制到进程B的地址空间,另外还需要注意的是,两次操作都需要实现进程地址空间到内核空间的切换,甚至还需要出现页高速缓存和TLB的刷新操作,这样导致整个效率要降低很多。最慢的肯定是使用套接字的方式,它不仅仅会像管道、FIFO这样出现两次的拷贝动作以及地址空间切换和缓存刷新问题,它更多出的一个步骤就是它还需要穿透两次网络协议栈,穿透协议栈的速度可以说是很慢的,中间甚至还需要硬件设备的辅助等等。
1.2.2.2、进程通信中的同步问题
接下来来看看问题2中是如何解决的,如果使用管道和FIFO以及消息队列这些方法,因为这些方法内部已经隐含了同步机制,所以在使用的时候并不需要关系这些同步问题。问题就在于使用套接字和内存映射过程中如何解决同步问题,使用内存映射也就意味着我们不希望使用读写函数进行操作(因为这些函数会进行数据在进程地址空间和内核空间之间的拷贝动作,使得效率变低),也就意味着没办法使用pread、pwrite这样原子性的读写操作。
对于使用套接字的情况,如果只是两个进程进行同步,而且数据量比较大,那使用TCP应该是一个比较好的选择,因为它提供了可靠性保障。如果数据量很小,那可以使用UDP,不过需要手动加入确认机制。对于使用内存映射方式实现,那可以在共享内存中加入互斥锁、条件变量、信号量等一些方法。
1.2.2.3、文件读写过程中的同步问题
对于问题3,同步的缘由要说明清楚就比较复杂,首先,进程调度的时机是由内核进行控制的,也就意味着一个进程在执行任何操作的过程中都可能被内核调度切换;然后,很多函数方法并不能保证对文件的读写是原子操作的,也就是说,很可能在执行到一半的时候,进程被调度出去,此时进入的另一个进程并不能发现原来的进程已经对这些内容进行了操作;最后,inode并不提供同步机制,所以文件的读写不能指望inode帮忙协调(即使inode提供了同步机制也还是不行,数据从进程地址空间到达内核空间,需要经过空间切换,数据拷贝这样的过程,我们无法保证这个过程是原子的)。所以种种原因导致不同的进程在读写同一个文件的时候存在同步问题。
- 第二种方法就是利用信号量,信号量就是为了解决进程间的同步问题而产生的,通过信号量,两个进程间就可以获取到对方是否在对文件进行操作的信息,这样也就解决了读写的同步问题。
- 第三种方法就是利用记录锁,记录锁可以对文件的任意区间进行加锁。和大部分锁同步相似,记录锁基本上也是使用劝告性上锁方式,也就是说,它并不能阻止一个不使用记录锁的进程强行进入临界区。而且,即使记录锁可以是强制性上锁也会存在问题,虽然可以保证使用记录锁的进程可以保证读写过程中的原子性,但是却没办法保证不使用记录锁的进程在读写过程中被其他进程打断。
- 还有一些相对要复杂的方式,如使用内存映射+锁(互斥锁、读写锁都可以)+条件变量(可以不用,不过一般需要)的方式实现两个进程间的锁同步。或者使用管道、FIFO中隐含的同步机制保证每次只有一个进程获取读取权限。甚至可以使用文件锁这样方式。当然这些方式不一定好用了。
接下来就是考察这些方式的效率问题,使用使用记录锁的效率比较高,它可以保证可以同时存在多个进程进行读操作,或者多个进程写入不同的区域。然后是信号量,相同的操作,记录锁的效率都要比信号量高一些。最后就是一些其他的方式,使用内存映射+锁同步的机制效率应该还是不错的,看个人实现了,使用管道这种同步效率肯定就比较低了,文件锁肯定很慢,因为它需要每次创建和删除一个新的文件,这必然非常慢。
但是不同的进程在写入文件过程中存在一个问题。在file数据结构中有一个很重要的指示变量f_pos(文件偏移量),它指示了当前文件操作的偏移值,文件读写都需要这个变量辅助告诉内核需要从哪里开始操作文件。现在的问题就是两个进程(即使是父子进程)中同一个文件的file数据结构是相互独立的,也就是说两个进程间不能互相知道对方的f_pos值,这时候的操作就会出现问题,例如,进程B从进程A继承后,两个进程中一个文件的偏移量都指向了1000,此时进程A写入了1000个字节的数据,A的偏移量走到了2000。但是进程B仍然以为文件偏移量是在1000位置,此时进程B写入2000个数据,这样就会把之前进程A写入的数据覆盖,这样就出现了问题。这种情况利用原子操作,如pread、pwrite也是没办法解决的。
如果是希望每次都从文件末尾写入数据(典型的如日志文件),则在使用open打开的文件的时候使用O_APPEND标识,这样每次在调入写数据的时候,会默认先将这个偏移量值移动到文件末尾,然后再开始写入,这样就解决了上述的问题。如果不想使用这种方式,也可以通过共享内存方式将文件的写入位置共享,通过竞争这个资源的方式获取写入位置。
下面是write系统调用的执行过程,fget_light用于从文件描述符获取文件对象实例;和fput_light用于更新文件引用计数;那与文件操作相关的是中间的三个方法:
- file_pos_read:获取文件当前偏移量
- vfs_write:数据写过程
- file_pos_write:更新文件偏移量
389 SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
390 size_t, count)
391 {
392 struct file *file;
393 ssize_t ret = -EBADF;
394 int fput_needed;
395
396 file = fget_light(fd, &fput_needed);
397 if (file) {
398 loff_t pos = file_pos_read(file);
399 ret = vfs_write(file, buf, count, &pos);
400 file_pos_write(file, pos);
401 fput_light(file, fput_needed);
402 }
403
404 return ret;
405 }
这样就可以知道,两个进程在同时写一个文件的时候,如果进程A调用了file_pos_read获取了文件的偏移量,那在其调用file_pos_write之前,进程B如果也调用file_pos_read,那进程B就获取了一个旧的文件偏移量,这样就容易出现覆写的情况发生。
1.3、线程同步问题
1.3.1、线程的产生
之前提到,Linux中线程是以一种轻量级进程的方式体现的,即父子线程有两个不同的PCB,但是它们的mm域指向了相同的内存域,当然files域也指向了相同的域。牢记住一点,父子线程见到的进程地址空间是一致的,这样线程同步问题就很容易想明白了。更多详细内容参考《深入Linux内核架构》、《Linux内核设计的艺术》,一时半会说不明白。
1.3.2、线程同步问题
- 同步问题1、父子线程间见到的进程地址空间是一致的,也就是说二者之间访问的任何一个变量都可能造成竞争,所以需要一种方法解决这之间的同步问题。
- 同步问题2、父子线程间打开同一个文件不仅仅只是共享一个inode的问题,它们甚至还共享同一个files数据结构,也就是说文件偏移量都是共享的,那如果线程A在调用write方法的时候,如果被线程B中断又会出现什么样的问题?
1.3.2.1、共享资源的线程同步
先讨论问题1,也就是共享资源的同步,因为父子线程之间见到的地址空间是一致的,如果同时多个线程操作同一个资源,那结果是不确定的。举例说明,线程A希望对变量x=100进行累加操作,这个过程其需要从内存中获取x的值存入CPU的通用寄存器中,然后将这个值累加,组后再从CPU的寄存器中写回到内存中(这个过程还没有考虑CPU中页高速缓存和内存间的同步问题),如果在线程A将累加后的值写回内存时,线程B也开始执行(线程B可能在另一个处理器上执行,这时候它真的与线程A同时执行了),它也从内存中读取了x的值,这样就出现问题了。线程B本应该在线程A之后执行,获取的值应该是101,结果却是100。即使在单处理器下,这种问题还是有可能出现,线程A在讲数据回写到内存时被线程B抢占,这时候也出现一样的情况。所以,只要是共享的资源,基本上都存在着资源的竞争同步问题。用于解决这些方法,可以采用的技术方法有:
- 互斥锁:非常简单的一种同步机制,在锁住状态下,所有希望进行加锁的进程都会被阻塞(可以设置超时)。锁的作用就是为了标识一个临界区,也就是说锁可以保证每次进入整个临界区的线程只有1个。如果只是单独使用锁,如果使用非阻塞的形式实现就可能会因为轮询而过度的消耗CPU资源,然而如果使用阻塞的形式,也可能会因为长时间的阻塞,所以锁也经常和条件变量配合进行使用,当然如果不使用条件变量,也可以使用信号并设置超时的方式避免线程长时间阻塞。还有一个需要注意的一个问题就是,如果一个线程获取到了锁并进入的临界区,此时如果因为异常而终止整个线程,如果这个线程在终止的时候无法处理这个被锁住的资源,那其他线程都会因此被阻塞,也就是所谓的死锁。还需要注意的就是,锁一般是不可重入的,也就是说一个线程无法获取一个锁两次,第二次试图获取锁的时候这个线程就会被阻塞,这样也就造成了死锁。还有就是在嵌套锁的时候,一定需要注意避免造成死锁。
- 条件变量:条件变量需要和锁配合使用,锁的作用是为了能够锁住资源,条件变量的作用则是为了实现对等待资源的等待。如果比喻一下,就好比我们去银行取钱,如果只使用锁,那每个人都必须在服务台前进行排队等待,如果加入条件变量,那就是我们进入银行后,我们会领域一个自己的服务编号,然后我们去旁边的沙发上休息,轮到自己的服务的时候,就会有一条通知,我们就知道自己可以去服务台接受服务了。条件变量也是如此,它首先获取锁(有点诡异),然后进入临界区,然后它发现没有可用的资源,然后就在等待队列上进行注册,然后释放锁,并进入睡眠。如果有线程调用pthread_cond_signal,就会有一个在等待队列上的线程被唤醒,这样其就可以进入临界区获取资源了。使用条件变量也有一些需要注意的地方,第一个就是最好不要在临界区内调用pthread_cond_signal方法,因为被唤醒的进程可能会比当前线程执行的更快,这样其可能会发现资源被锁住而再次进入等待队列。第二个需要注意的是,谨慎使用pthread_cond_broadcast,因为其会唤醒整个队列上的所有线程,这样容易造成“惊群效应”。
- 读写锁:一般情况下使用锁已经足可保护临界区范围内的资源了,使用读写锁的缘由就是,在一些特殊的场景,我们进入临界区更大的概率是为了读取临界区范围内的数据(例如数据库资源)。普通的互斥锁并不提供这种这种读写的偏向。读写锁可以保证一次可以有多个读者进入临界区,而每次只有1个写者进入临界区,读者和写着不能同时进入临界区。这样明显可以提高整个资源的查询效率。但是读写锁也需要注意一些问题,第一个情景就是,读者和写者的优先权问题,线程A获取到了读锁,此时线程B试图获取写锁被阻塞,此时如果线程C是否可以获取读锁?如果可以,即读者优先级高于写者,那就可能会造成读者的饥饿问题;另一种情况就是,线程A获取到了写锁,线程B试图获取读锁进入阻塞队列,如果线程C试图获取写锁,是否线程C可以排在线程B前面?如果可以,即读者优先级高于写者,那就可能造成读者的饥饿问题。所以在使用前,不妨写两个小程序测试一下这两个特性,看看使用的读写锁是否存在这两种问题。
- 记录锁:记录锁可以简单的理解成在文件中使用读写锁,它可以保证可以有多个线程(进程)读文件,只有1个线程(进程)写文件,而同时要求读者和写者都拥有文件。
- 信号量:信号量设计的目的是用于解决进程间的同步问题,但是也不妨碍其用于解决线程同步问题。
1.3.2.2、线程同步下的文件读写问题
线程同步下文件读写问题,
----mark,有些问题还没查清楚,下次补上
1.4、小结
要分析同步问题,首先需要分清进程与线程之间的区别,分别理解在操作系统中进程与线程的表达,然后需要理解文件在内核中的表现形式,最后还需要理解系统中read和write方法的整个执行流程,这样才能一点点的分析需要执行同步的场景。进程和线程间都需要进行同步,不仅仅是共享的内存需要同步,文件的读写也需要同步。基本上只要分清楚了这些,感觉大部分同步模型都相对容易理解了。