在多线程编程中,经常遇到的两个概念,一个是并发,另一个是并行。并发是指能够同时创建多个操作系统的调度单元,比如进程和线程(在Linux里面这两者都使用Task表示)。但是并发的任务必须得获取到CPU资源才能运行,否则就处于休眠状态。现在大部分计算机的CPU都具有多个核,将并发的任务安排到这些个核上同时运行,这就是并行的概念了。

互斥和条件变量

并行常常会受到很多限制,因为一个任务常常需要不同的资源,比如内存资源、以及非内存资源。其实按照现代计算机的体系架构,非内存资源也会映射到某个内存区域供CPU使用,所以归根结底,CPU上的任务都是在跟内存的某一块区域打交道。那么问题来了,如果两个并行的任务想访问同一块内存区域,就会产生竞争关系,就需要某种协调机制。

如果不做协调会怎么样?如果两个任务同时往内存中一段长度为100个字节的区域进行写操作,同时写入各自的100字节,那么很可能的结果是这个内存区域一部分的内容是任务一写入的,另一部分的内容是任务二写入的,但是这两个任务无法获悉这种情况,都以为内存中的那款区域写入的是自己的内容,导致意外的结果。

有没有办法避免上面的结果呢?注意,上面的例子中两个任务所竞争的是100个字节的内存资源。如果把这个资源的范围缩小到1个字节,那么不管是任务一写入还是任务二写入,这个字节要么就是任务一写入的内容,要么就是任务二写入的内容,不会出现混杂的情况。这种不会出现内容混杂的操作称为原子操作。现代的计算机通常可以支持超过1个字节的原子操作,最大能达到4个字节或者8个字节。但是超过了就不行了。

原子操作只是解决了小颗粒的资源的混杂,但是对于大粒度的资源,需要其他协调机制。最简单的一种机制是所有权机制,只有资源归某任务所有时,这个任务才能访问该资源。这个通常是采用互斥锁(mutex)技术来实现的。当一个任务需要访问某个资源时,就必须先获得跟这个资源相对应的互斥锁,否则只能等待。当拥有一个互斥锁的任务使用完相应的资源时,必须主动释放该互斥锁,以便其他任务能够获得该锁,并访问相应的资源。从这个角度看来,互斥锁是排他性的。

另外需要注意的是,一个互斥锁只能对应一个资源,而不能对应两个资源。所以使用互斥锁的时候,所有的任务必须对这个互斥锁所对应的资源代表的含义达成一致,否则就会出问题。

互斥锁存在一个毛病,那就是谁上锁就必须由谁来解锁,而且只能一个任务上锁,并不是所有的场景都需要这么强的排他性。有些情况下,任务们依赖于某些外部条件的达成才能继续执行,在这个外部条件达成前,这些任务暂时先休眠着。当外部条件到来时,再唤醒这些沉睡的任务们,让他们能够继续执行。这个场景中的问题在于,这个外部条件不属于某个任务,所以不同用所有权的方式,也就是上互斥锁的方式来解决。在各个线程库(pthread)里面,这种事件通知可以用条件变量(condition variables)来处理。

一个不太恰当的比方

如果需要用一个比喻来解释并发的话,我会选择办公室这个概念。办公室指代的是计算机硬件。办公室里有一个经理,用来指挥和协调各方面的事务。这个经理其实就是操作系统。然后办公室里面需要有人干活,这些人就是办公室的职员,也就是操作系统管理的任务。经理拥有权利,可以允许哪个职员进入办公室,并且可以要求某个职员离开办公室。每个职员都被安排一些工作,但是职员必须借助一些资源才能完成这些操作。比如现在的办公环境中,大部分工作都是在电脑中完成的,所以职员必须配备电脑。获得电脑的职员可以看成是操作系统中获得CPU事件的任务。除了电脑以外,还涉及一些其他资源,比如开会需要用到会议室,打印需要用到打印机。

像打印机和办公室这些资源都是共享的,使用的时候需要进行协调和同步。比如,对于打印机,一个人使用的时候,其他人就只能等待。只有当前的使用者结束离开的时候其他人才能上前。所以使用打印机是一个所有权的问题,是适用互斥锁的场景。但是,当很多人想使用打印机的时候还涉及到排队的问题。通常打印机所在的打印机室比较小,只能容纳一个人,不可能很多人在其中排队,所以大部分人应该在工位或者其他地方等着。当前一个人使用完打印机之后,下一个人才会到打印机室使用打印机。这里涉及到一个条件状态,就是“打印机变空了”。当一个职员使用完打印机之后,“打印机变空了”这个状态就可以通知到那些想使用打印机的人。这个场景就比较适用前面提到的条件变量了。

pthread对应的操作

再来看pthread手册(来自macOS的manpages)中针对互斥锁(mutex)的接口

     int pthread_mutex_destroy(pthread_mutex_t *mutex)
             Destroy a mutex.

     int pthread_mutex_init(pthread_mutex_t *mutex,
             const pthread_mutexattr_t *attr)
             Initialize a mutex with specified attributes.

     int pthread_mutex_lock(pthread_mutex_t *mutex)
             Lock a mutex and block until it becomes available.

     int pthread_mutex_trylock(pthread_mutex_t *mutex)
             Try to lock a mutex, but do not block if the mutex is locked by
             another thread, including the current thread.

     int pthread_mutex_unlock(pthread_mutex_t *mutex)
             Unlock a mutex.

和条件变量(condition varaible)的接口

     int pthread_cond_destroy(pthread_cond_t *cond)
             Destroy a condition variable.

     int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t
             *attr)
             Initialize a condition variable with specified attributes.

     int pthread_cond_signal(pthread_cond_t *cond)
             Unblock at least one of the threads blocked on the specified con-
             dition variable.

     int pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t *mutex,
             const struct timespec *abstime)
             Unlock the specified mutex, wait no longer than the specified
             time for a condition, and then relock the mutex.

     int pthread_cond_wait(pthread_cond_t *, pthread_mutex_t *mutex)
             Unlock the specified mutex, wait for a condition, and relock the
             mutex.

大部分操作的目的在对应的英文里面都加以解释了。需要注意的是pthread_cond_wait这个操作,它针对的是条件变量,却带了一个互斥锁做参数。用一个例子来解释这个参数,假设你用一个互斥锁来控制办公室里面的打印机的使用,能够使用该打印机的人必须先拥有这个互斥锁。当一个职员使用完了之后,他通过条件变量通知其他职员“打印机变空了”这个状态,然后下一个职员到了打印机室,他必须在使用打印机之前,先获得该互斥锁(前面提到打印机室只能容纳一个人,这个互斥锁可以比喻成打印机室的门,当一个职员使用打印机时,先把打印机室门关上,告诉别人打印机被人占用了)。

(完)