操作系统的主要内容分为三个部分,分别是虚拟化、并发和持久性,上篇提到了虚拟化,这篇记录一下并发。
操作系统不但为每个进程创建巨大的、私有的虚拟内存的假象,还为单个运行的进程提供一种新的抽象—线程。经典观点是一个程序只有一个执行点,但多线程会有多个执行点。每个线程类似独立的进程,但有一个区别:多线程共享地址空间,从而能够访问相同的数据。
那如果有两个线程运行在一个处理器上,从一个线程切换到另一个线程的时候必然会发生上下文切换,线程之间的上下文切换和进程间的类似。对于进程,我们将状态保存到进程控制快(PCB),那现在我们需要一个或多个线程控制块(TCB)来保存每个线程的状态。但与进程相比,线程之间的上下文切换的地址空间保持不变,即不需要切换当前使用的页表。
那我们对比一下传统的进程地址空间模型,只有一个栈,且通常位于地址空间的底部(图左)。
但在多线程中,每个线程独立运行,可以调用各种例程来完成正在执行的工作,且不只有一个栈(图右)。我们常说栈存储局部变量,堆存储全局变量,那这里其实堆区域是公用的,栈区域是每个线程有自己的地址空间。
看到这张图相信你可能会思考到内部碎片问题,这种担心是对的。幸运的是,栈通常不会很大。
当上面提到了多线程会共享同一个空间,但在一个线程想要读取a的值时,另一个线程中断并修改了a的值,我们不希望发生这样的事,所以此时我们希望a的指令是原子性的—全部或没有。
在这里有4个术语可以了解一下
上述是并发编程的一个最基本的问题,我们希望原子式的执行一系列指令,但由于单处理器上的中断,我们做不到。于是锁的概念应运而生,我们在源代码中加锁,放在临界区周围,保证临界区能够像单条原子指令一样执行。
举个例子
lock_t mutex;
...
lock(&mutex)
balance += 1
unlock(&mutex)
锁其实就是一个变量,因此我们需要声明一个锁变量(如上面的mutex)才能使用,这个锁保存了某一时刻的状态。它要么是可用的—表示没有线程持有锁,要么是被占用的—表示有一个线程持有锁,正处于临界区。
关于lock()和unlock()函数
调用lock()尝试获取锁,如果没有其他线程持有锁,该线程会获得锁,进入临界区,这个线程被称为锁的持有者,此时如果另外一个线程对相同的锁调用lock(),因为锁被持有者持有,该调用不会返回,这样当持有锁的线程在临界区时,其他线程就无法进入临界区。
锁的持有者一旦调用unlock(),锁就变成可用了,如果没有其他等待线程,锁的状态就变成可用;如果有等待线程(卡在lock0里),其中一个会注意到锁状态的变化,获取该锁,进入临界区。
锁为我们提供了最小程度的调度控制,我们把线程视为程序员创建的实体,但是被操作系统调度,具体方式由操作系统选择。锁让程序员获得一些控制权,通过给临界区加锁,可以保证临界区内只有一个线程活跃,使操作系统调度的混乱状态变得更为可控。
虽然锁简单好实现且有效,但在某些场景下,这个解决方案效率会大大降低。
比如当两个线程在单处理器上运行时,当一个线程持有锁,被中断,第二个线程获取锁发现已经被持有,因此它开始自旋。
不过自旋是非常消耗CPU资源的,所以我们希望思考一个方法可以解决这个问题。
第一个最简单的方式就是在需要自旋的时候,线程主动放弃CPU。因此,让出线程本质上取消调度了自己。但这种情况由于线程上下文切换需要更久的时间,解决不了当很多线程抢占同一把锁的问题,更糟的是,我们还没考虑到饿死的问题。
那第二个想法是使用队列,用休眠替代自旋。但会导致某一个进程将永远休眠的情况,这里不多赘述。
上面我们提到,锁是一种数据结构,那用到并发队列,是不是可以再往下想到并发链表呢?感兴趣可以自行了解一下。
基于刚刚锁的自旋问题,我们提出一个解决方案--条件变量
线程可以使用条件变量来等待一个条件变成真,条件变量是一个显式队列,当某些执行状态不满足时,线程可以把自己加入队列等待该条件;当另外某个线程改变了上述状态时,就可以唤醒一个或者多个等待线程(通过在该条件上发信号),让它们继续执行。
声明条件变量代码如下
pthread_ cond_ t c;
这里声明的 c 是一个条件变量,条件变量有两种相关操作: wait0 和 signal()
线程要睡眠的时候调用 wait();线程想唤醒等待在某个条件变量上的睡眠线程时调用 signal()
信号量是有一个整数值的对象,在POSIX标准中,可以用两个函数来操作他
sem_wait() sem_post()
因为信号量的初始值能决定其行为,所以首先要初始信号量,才能调用其他函数与之交互
#include
sem_t s;
sem_init(&s,0,1);
上面代码中申明了一个信号量s,通过第三个参数,将其的值初始化为1;第二个参数,设置为0表示信号量是在同一进程的多个线程共享的。
to be continue...