树莓派开始,玩转Linux23:多任务与同步
上一章提到了IPC,实际上它涉及一个关键问题:计算机的并发性。Linux系统是一个支持并发(Concunrrency)的操作系统。
并发系统可以同时执行多个任务。多个进程通过IPC的数据沟通,可以合作完成一个复杂任务。然而,并发系统并不简单,必须解决同步的问题。
1.并发与分时:
在过去很长时间里,计算机使用的都是单核CPU。每个时刻,单核的CPU只能执行一条指令。从指令的角度看,单核CPU计算机不能并发。
但单核CPU计算机可以同时运行多个任务。这种并发是通过分时(Time-Sharing)来实现的。所谓的"分时",就是把时间分配给多个服务对象。
这就好像一位妈妈同时照顾三个婴儿。她先给第一个孩子端上饭,然后给第二个孩子倒水。倒完水之后,她又跑去给第三个孩子梳头。妈妈把自己的时间分给了三个孩子。虽然每个特定的时刻,妈妈只能照顾一个孩子,但三个孩子还是能一直感受到妈妈的温暖。照顾多个进程的CPU类似于照顾三个孩子的母亲,如图所示。
照顾多个进程的CPU类似于照顾三个孩子的母亲
和母亲照顾多个孩子的情况类似,CPU的工作时间也可以分配给多个进程。CPU执行进程A一段时间,就换进程B继续执行。切换进程的工作由操作系统负责,操作系统会先把进程A的状态更新到进程A的描述符中,再根据进程B描述符中的记录,从进程B上次暂停的地方继续进行下去。
这样,多进程协作的目的就可以实现。即使在单核计算机上,我们也可以边浏览网页边听音乐,也就是说,单核CPU也可以让多个任务同时推进。
现代的多核CPU,可以同时执行多个指令,从基础的物理层面实现了并发。也就是说,现代的计算机看起来像是多位保姆照顾多个婴儿。即便如此,操作系统还是会用分时的方式来安排任务。原因很简单,计算机中的任务总数很容易超过CPU可以同时执行的指令总数。此外,通过分时系统,计算机的运行效率也能有效提升。我们将在讲解调度器的时候,深入这一点。
2.多线程:
第节中的并发是通过多个进程实现的。多进程加上IPC,就已经提供了丰富的多任务协作方式。如果调出其中的一个进程看,它的内部只进行一个任务,不会有并发。进程就好像一位专心写作业的小朋友,不会同时看电视。这种注意力单一、每个时刻只做一件事的工作方式,叫作单线程(Single-Threading)。我们前面见过的进程,都是单线程进程。
但程序员很多时候会在一个进程内部运行多线程(Multi-Threading)。多线程允许在一个进程中同时执行多个子任务。由于我们要同时关照多个线程的状态,进程的结构必须发生变化。
· 进程描述符需要记录每个线程的相关信息,特别是它们的状态
和进度。这一点和进程的情况类似。
· 操作系统需要把适当的计算时间分配给进程。内核调度器在分
配计算时间时,必须把各个线程考虑在内。
· 进程空间中必须有多个栈。
前两者和进程的情况类似。着重看最后一点,即进程空间中必须有多个栈。栈记录着函数调用的顺序,最下方的帧是唯一一个激活函数。既然多线程是多任务并发,那就意味着会有多个函数处于激活状态,并同时运行。比如下面的多线程程序:
在C语言中,可以用pthread的一系列函数来操作多线程。我们把某个函数放入到新线程中执行,如func1(),程序输出如下所示。
程序的运行流程是一个多线程的流程,如图所示。
多线程流程
从main()到func3()再到main()构成一个线程,而func1()和func2()构成另外两个线程。
当程序创建一个新的线程时,必须为这个线程建一个新的栈,每个栈对应一个线程。当某个栈执行到全部弹出时,对应线程完成任务。因此,多线程的进程在内存中有多个栈。多个栈之间以一定的空白区域隔开,以备栈的增长。对于多线程来说,由于同一个进程空间中存在多个栈,任何一个空白区域被填满都会导致栈溢出的问题。
进程空间上需要调整栈的部分。一个线程与其他线程共享内存中的程序段、堆和全局数据。这些部分的组织方式也和单线程进程类似。由于多线程共享了很多内存区域,它们都可以直接读写堆上的内容,线程间的数据共享变得很简单。因此,多线程的数据交流成本要比多进程低得多。这也是程序员使用多线程的一大原因。
3.竞态条件:
多进程和多线程都实现了并发。并发系统实现了多任务协作,但容易产生竞态条件(Race Condition)。如果多个任务可以共享数据,特别是可以同时修改某个数据时,就很有可能发生竞态条件。
我们来看竞态条件在现实生活中的例子。如果一节火车有100张票,同时在10个售票窗口销售。每位售票员卖完一张票之后,就打电话告诉总部卖出去一张票,可售卖的票数就会减1。
用一个多线程程序来重现上述情形。程序用全局变量i存储剩余的票数。多个线程不断地卖票,也就是从i中减去1,直到剩余票数为0,因此每个都需要执行如下操作 :
每个线程会进行两件事。一件事是判断是否有剩余的票,即判断i是否等于0。另一件事是卖票,即从i上减去1。这两件事情之间存在一个时间窗口,其他线程可能在此时间窗口内执行卖票操作,即从i中减1。但之前的卖票线程已经执行过了判断,不知道i发生了变化,所以会继续执行卖票,以至于卖出不存在的票,让i成为负数。对于一个真实的售票系统来说,这将成为一个严重的错误。
在并发情况下,指令执行的先后顺序由内核决定。同一个线程内部,指令按照先后顺序执行,但不同线程之间的指令很难说清哪一个会先执行。这个时候,如果并发任务可以同时读取同一块数据,就会造成结果难以预测的情况。因此,在并发系统中,如果运行的结果依赖于不同线程执行的先后顺序,则会造成竞态条件。
4.多线程同步:
对于并发程序来说,同步(Synchronization)是指在一定的时间内只允许某一个任务访问某个资源。同步可以解决竞态条件的问题。比如,某段时间内只能有一个售票员查询票数并售出,其他售票员在此期间不能售票,就不会有竞态条件的问题。
以多线程为例,多线程同步就是在一定的时间内只允许某一个线程访问某个资源。在多线程中,我们可以通过互斥锁(Mutex)、条件变量(Condition Variable)和读写锁(Reader-Writer Lock)来同步资源,分别来看它们的功能。
1.互斥锁
互斥锁是一个特殊的变量,它有锁上和打开两个状态。互斥锁一般被设置成全局变量。打开的互斥锁可以由某个线程获得。一旦获得,这个互斥锁会锁上,此后只有该线程有权打开。其他想要获得互斥锁的线程,要等到互斥锁再次打开的时候。
我们可以将互斥锁想象成为只能容纳一个人的洗手间,当某个人进入洗手间时,可以从里面将洗手间锁上。其他人只能在互斥锁外面等那个人出来,才能进去。在外面等候的人并没有排队,谁先看到洗手间空了,就可以先冲进去。上面的问题很容易使用互斥锁模拟,每个线程的程序可以改为:
变量mu就是互斥锁。第一个执行mutex_lock()的线程会先获得互斥锁。其他想要获得互斥锁的线程必须等待,直到第一个线程执行到mutex_unlock()释放互斥锁,才可以获得互斥锁,并继续执行线程。因此线程在进行mutex_lock()和mutex_unlock()之间的操作时,不会被其他线程影响。
每个线程必须遵守互斥锁的上述使用规则,才能保证互斥锁发挥作用。如果某个线程不尝试获得互斥锁,而是直接修改变量i,那么互斥锁就失去了保护资源的意义。互斥锁的效力在于多线程共同遵守规则,它本身并不能硬性阻止线程对i的修改。总之,互斥锁机制需要程序员自己写出完善的程序来发挥互斥锁的功能。下面介绍的其他机制也是如此。
2.条件变量
条件变量是另一种常用的变量。它也常常被保存为全局变量,并和互斥锁合作。举个例子,老板请了100个工人,让每个工人负责装修一个房间。当有10个房间装修完成的时候,老板就会去检查已经装修好的10个房间,然后通知这10个工人一起去喝啤酒。
我们可以并发地装修,也就是开100个线程,让每个线程对应一位工人的工作。但在多线程条件下,会有竞态条件的问题。其他工人有可能会在该工人装修好房子和检查之间完成工作。采用下面方式可以解决这个问题。
上面使用了条件变量。条件变量cond除了要和互斥锁mu配合之外,还需要和另一个全局变量num配合。这里的num表示装修好的房间数。这个全局变量用来构成所谓的"条件"。具体思路如下。我们在工人装修好房间,也就是执行num=num+1之后,去检查已经装修好的房间数是否小于10 。由于互斥锁被锁上,所以不会有其他工人在此期间装修房间,也就是改变num的值。如果该工人是前10个完成的人,那么就调用cond_wait()函数。
cond_wait()做两件事情,一个是释放互斥锁mu,从而让别的工人可以建房;另一个是等待条件变量cond的通知。这样,符合条件的线程就开始等待。当"第10个房间已经修好"的通知到达时,condwait()会再次锁上mu。线程的恢复运行,执行下一句代表喝啤酒的printf(“drink beer”)。从这里开始,直到mutex_unlock(),就构成了另一个互斥锁结构。
前面10个调用cond_wait()的线程如何得到通知呢?我们注意到elseif,即修建好第11个房间的人负责调用cond_broadcast()。这个函数会给所有调用cond_wait()的线程发通知,以便让前面10个等待的线程恢复运行。
条件变量特别适用于多个线程共同等待某个条件发生的情况。如果不使用条件变量,那么每个线程就需要不断尝试获得互斥锁并检查条件是否发生,这样大大浪费了系统的资源。
3.读写锁
读写锁与互斥锁非常相似,但它对读写做出了区分。如果一个共享资源只有读取而没有写入操作,那么多个任务可以同时读取,而不用担心竞态条件的发生。一旦有一个进程开始写入,那么其他想要读取和写入的进程必须等待该进程完成写入,才能继续操作。因此,读写锁中包含了两把锁,即读锁(R)和写锁(W)。
应用程序应该用R锁来控制读取操作。如果一个线程获得R锁,读写锁允许其他线程继续获得R锁,而不必等待该线程释放R锁。也就是说,多个进程可以同时读取同一资源。W锁用来控制写入操作,同一时间只能有一个线程获得W锁。不过,在获得W锁之前,线程必须等到所有持有共享读取锁的线程释放掉各自的R锁,以免自己的写入操作干扰到其他线程的读取。
我们这一部分介绍了一些常见的多线程同步方法。多进程同步的方法也类似,这里不再赘述。我们看到,多任务同步的实施有赖于程序员的编程付出,但在多核计算机和多主机集群流行的大背景下,多任务程序又是提高资源利用率的关键手段,因此多任务同步就变得极为重要。