操作系统学习笔记—并发

操作系统的主要内容分为三个部分,分别是虚拟化、并发和持久性,上篇提到了虚拟化,这篇记录一下并发。

多线程

操作系统不但为每个进程创建巨大的、私有的虚拟内存的假象,还为单个运行的进程提供一种新的抽象—线程。经典观点是一个程序只有一个执行点,但多线程会有多个执行点。每个线程类似独立的进程,但有一个区别:多线程共享地址空间,从而能够访问相同的数据。

那如果有两个线程运行在一个处理器上,从一个线程切换到另一个线程的时候必然会发生上下文切换,线程之间的上下文切换和进程间的类似。对于进程,我们将状态保存到进程控制快(PCB),那现在我们需要一个或多个线程控制块(TCB)来保存每个线程的状态。但与进程相比,线程之间的上下文切换的地址空间保持不变,即不需要切换当前使用的页表。

那我们对比一下传统的进程地址空间模型,只有一个栈,且通常位于地址空间的底部(图左)。

但在多线程中,每个线程独立运行,可以调用各种例程来完成正在执行的工作,且不只有一个栈(图右)。我们常说栈存储局部变量,堆存储全局变量,那这里其实堆区域是公用的,栈区域是每个线程有自己的地址空间。

操作系统学习笔记—并发_第1张图片

看到这张图相信你可能会思考到内部碎片问题,这种担心是对的。幸运的是,栈通常不会很大。

原子性 

当上面提到了多线程会共享同一个空间,但在一个线程想要读取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...

你可能感兴趣的:(操作系统,CSAPP,学习)