【2021/7/19 更新】【梳理】简明操作系统原理 第七章 线程和锁(docx)

配套教材:
Operating Systems: Three Easy Pieces Remzi H. Arpaci-Dusseau Andrea C. Arpaci-Dusseau Peter Reiher
参考书目:
1、计算机操作系统(第4版) 汤小丹 梁红兵 哲凤屏 汤子瀛 编著 西安电子科技大学出版社

在线阅读:
http://pages.cs.wisc.edu/~remzi/OSTEP/
University of Wisconsin Madison 教授 Remzi Arpaci-Dusseau 认为课本应该是免费的
————————————————————————————————————————
这是专业必修课《操作系统原理》的复习指引。
需要掌握的概念在文档中以蓝色标识,并用可读性更好的字体显示 Linux 命令和代码。代码部分语法高亮。
文档下载地址:
链接:https://pan.baidu.com/s/16GJC78m_WGg1zEzMxSmIrQ
提取码:0000

七 线程和锁

第一章我们介绍过进程。现在我们介绍一种新的抽象:线程(thread)。一个多线程的程序有多个PC(程序计数器),每个PC指向一个线程的下一条准备执行的指令。线程很像进程,但两者亦有区别:一个进程的全部线程共用一个进程的地址空间,而且都可以访问该进程的全部数据。可以把线程看作轻量级的进程。
虽然用多线程可以做到的事,用多个进程来做也可以,但是线程之间共用地址空间,因此更容易分享需要共用的数据。进程之间的数据是很少共享的。

每个线程都要有自己的栈,因此一个多线程的进程含有多个栈,用于保存各个线程自己的局部变量、函数参数和返回值等。但是一个多线程的进程一般只有一个堆。
每个线程都要有自己的一组寄存器。因此,在单个CPU核心上通过上下文切换选择不同的线程来执行时,也要将当前线程的寄存器数据保存在线程控制块(thread control block,TCB)中。同样,TCB还要保存线程的其它状态信息,以确保在切换回原线程时能够顺利恢复进度并继续执行。但是,切换线程时,地址空间不变,也就不需要向TLB中载入新的页表项。
TCB一般包含:线程标识符(TID)、寄存器(包括PC、状态寄存器(标志寄存器),即程序状态字(PSW))、线程状态(执行 / 就绪 / 阻塞……)、优先级、栈指针、线程专有存储区(用于切换线程时保护现场)、信号屏蔽情况等。

引入线程的原因至少有两个:
一是为了防止进程请求I / O时打断本可以继续执行的任务:如果一个进程有多个线程,可以用其中一个线程发起I / O请求,剩下的线程继续进行计算,也可以准备请求或同时请求新的I / O。于是当一个线程被阻塞时,CPU就可以调度该进程的其它线程继续在当前核心上执行。Web服务器、DBMS(数据库管理系统)等服务器级大型应用几乎无一例外地充分进行了多线程优化。
二是对并行(parallel)执行的要求。线程的调度、创建和销毁需要的开销更少。在传统的操作系统中,进程是独立调度和分派的基本单位,因而进程是能独立运行的基本单位。在每次被调度时都需要上下文切换,开销较大。而在引入线程的操作系统中,已把线程作为调度和分派的基本单位,因而线程是能独立运行的基本单位。当线程切换时,仅需保存和设置少量寄存器内容,切换的代价远低于进程。在同一进程中,线程的切换不会引起进程的切换;但从一个进程中的线程切换到另一个进程中的线程时,必然就会引起进程的切换。
在创建或撤消进程时,系统都要为之分配和回收进程控制块、分配或回收其它资源,如内存空间和I / O设备等。OS为此所付出的开销,明显大于线程创建或撤消时所付出的开销。类似地,在进程切换时,涉及到上下文切换,而线程的切换代价也远低于进程。例如,在Solaris 2中,线程的创建要比进程的创建快30倍,线程上下文切换要比进程上下文切换快5倍。此外,由于一个进程中的多个线程具有相同的地址空间,线程之间的同步和通信也比进程的简单。在一些OS中,线程的切换、同步和通信,都无需操作系统内核的干预。

运行下面的代码若干次:

#include
#include
#pragma warning(disable:4996)

inline void f(const char* _Str) { puts(_Str); }

int main() {
std::thread t1(f, “A”), t2(f, “B”);
t1.join(); t2.join();
return 0;
}
虽然输出"A"的线程t1的创建总是先于输出"B"的线程t2,但是输出结果并不总是保持相同:

(注:在Linux下,运行g++ demo.cpp -o demo -O2 -m64 -std=c++1y -pthread进行编译)
可见,线程开始执行或执行完毕的次序与其创建顺序无关。线程可以在被创建之后、创建者返回之前就开始运行,也可以在这之后一段时间才运行。

运行下面的代码若干次:

#include
#include
#pragma warning(disable:4996)

volatile unsigned c = 0;

inline void f(const char* _Str) {
printf("%s: Begin.\n", _Str);
for (unsigned i = 0; i < 1e8; ++i)++c;
printf("%s: End.\n", _Str);
}

int main() {
puts(“Main: Begin.”);
std::thread t1(f, “A”), t2(f, “B”);
t1.join(); t2.join();
printf(“Main: End. c = %u\n”, c);
return 0;
}
volatile关键字修饰的变量在被访问时必须老老实实从内存中访问,不能对需要访问该变量的代码进行优化(例如:将上一次读取的该变量的值保存到寄存器,下次读取时直接使用寄存器中的保存结果而不管其在内存中的值是否已改变)。
你应该会认为:c的值在最终输出时一定是200 000 000。但是:

连最终输出的值也无法确定!这中间发生了什么呢?
过程f的循环部分的汇编代码大概是这样的:

mov 0x8049a1c, %eax
add $0x1, %eax
mov %eax, 0x8049a1c
变量c位于0x8049a1c中,它在循环中会先被放到eax里,然后增加1,再把寄存器eax的值放回内存中去。
假设线程1开始执行时,c = 50。它将c移到eax中,然后递增1。但是这时定时器中断引发了上下文切换,于是eax的值被保存到线程1的TCB中,线程1暂停。而后,线程2开始运行。它先将c移到eax中,但此时线程2获得的c的值是50而不是51。这次线程2能够在定时器中断到来前执行完一次循环,将eax中的值写回到内存,此时c = 51。下一次又轮到线程1运行了,它从上次中断的位置开始继续执行,将线程1暂停执行时eax的值写回内存,此时c = 51。可以看出,其实c应该被增加2次,值为52;但实际上只增加了1次,值为51。
多线程的情况下,对共享资源进行混乱操作,导致整个处理过程变得混乱,这种情况称为竞争条件(race condition,也称竞争危害、竞争冒险,race hazard)。这导致了计算机的计算结果不再是可确定的。如果用多个线程同时执行某段代码会导致竞争条件,就称这段代码所在的位置为临界区(critical section,也称关键区、关键段)。也就是说,临界区中的代码是访问共享资源的代码。为了避免这种情况,就不可以允许多个线程同时执行临界区的代码。换个说法就是互斥(mutual exclusion,mutex):临界区的代码被一个线程执行时,不允许被其它线程执行。

原子操作(atomic operations)是建设强大而可靠的计算机系统必不可少的一种思想。无论是我们刚才讨论和后续将讨论的简单代码中,还是在文件系统(将在后续章节学习)、DBMS(数据库管理系统)乃至分布式系统(distributed system)里,都充分体现了这种思想。“原子”一词简单解释为“All or nothing”,也就是说:对于一个原子动作,要么将其执行完毕,要么不要执行。将一系列指令组合为原子操作后,就称为事务(transaction)。这是数据库并发(concurrency,允许多个线程同时访问)中的最基本的概念之一。一个事务中的一系列指令,要么全部执行完毕,要么不能开始执行;如果尚未执行完毕而发生故障,则需要撤销(undo)已经进行的操作。
如果上例中的代码要正确执行,那么就要将一次循环包含的3条汇编指令合并为一个原子操作。当中断发生时,不能暂停执行该操作,而是待其执行完毕后再处理中断(切换到其它线程)。硬件提供了同步原语(synchronization primitives)的支持。在硬件与操作系统的支持下,可以将一系列操作合并为原子操作。
可见,出现竞争条件的根本原因是:线程的执行被调度器打断,导致本应一次性连续执行完毕的部分语句在执行完毕前被暂停。

为了整体执行临界区的操作,需要用到锁(lock)。锁是一种变量,用于刻画锁的状态。一个锁要么不被进程使用(称为available、unlocked或free),要么在一个线程执行临界区的代码时被该线程独占(称为acquired,locked或者held)。锁变量还可以保存其它的信息,例如占用该锁的线程,或者等待获得该锁的线程队列。当然,这些信息在锁这个类中不是公有成员,对用户不可见。
锁的引入,是为了仅允许单个线程操作为多个线程所共享的资源,防止不同的进程在不应读取到不同数据的时候读取到不同的数据,或者先对共享资源做出的更改失效(被后来的进程错误覆盖)。更广义地说,锁的引入,是用于将一系列操作合成为单个原子操作。

为了考察一个锁是否能正确、高效地发挥作用,有必要建立相应的评估准则:
第一个评价标准是互斥。换句话说,就是要考察一个锁是否确实能阻止其它线程访问正在被执行的临界区。
第二个评价标准是公平。意即每一个请求获得锁的线程是否在不太长的时间内总能获得这个锁(即不会饥饿)。
第三个评价标准是性能。考察标准分成单线程、单核心多线程和多核心多线程三种情况。

POSIX标准提供了头文件<pthread.h>实现多线程,其中就包括锁结构pthread_mutex_t。但在使用锁时需要注意,获得锁的操作pthread_mutex_lock、pthread_mutex_trylock以及pthread_mutex_timedlock都有可能会失败。pthread_mutex_lock在上锁失败后会一直阻塞,直到锁被占有的进程释放;pthread_mutex_timedlock在上锁失败以后最多阻塞一段时间,若锁还未释放,将立即返回。锁只是一种特殊的数据结构,如果程序员没有按要求提供上锁失败后的正确的处理机制,那么上锁失败以后,程序依然可以继续执行。也就是说,此时多个线程将有机会同时位于临界区内。
C++11开始,也提供了互斥锁的实现,位于头文件<mutex>中。

早期的单核计算机中,线程获得锁和释放锁时,分别通过调用硬件指令禁用中断和启用中断来实现。这个方法非常简单,但有许多缺点。首先,禁用和启用中断显然是特权级指令。如果我们要让程序可以实现锁的功能,那么就要允许它们调用特权指令。这自然是很危险的:一个恶意程序可以在获得锁后开始跑一个死循环,于是它就成功霸占了全部CPU时间。其次,禁用和启用中断是对单个CPU核心控制的。如果CPU有多个核心,仍然可以通过其它核心来访问该临界区。此外,禁用中断期间,如果其它硬件请求了中断,这些中断就会丢失,这将会导致非常严重的问题:例如,在禁用中断期间,硬盘完成了一次读取操作,向CPU发出相应的中断。如果这个中断丢失了,那么操作系统怎样得知,何时应当让因为请求读取磁盘而阻塞的进程继续执行呢?并且,中断的开启关闭(屏蔽或解除屏蔽)操作在CPU上执行起来比一般的指令要慢。
所以,禁用中断只在非常少的情况下使用。例如,操作系统在维护自己的数据结构期间会屏蔽中断。这也在一定程度上阻止了处理中断带来的一些问题。

有没有其它的方法实现锁?也许可以尝试用专门的标记位来标记锁。当一个线程准备进入临界区时,调用上锁的命令,标记为置位。当从临界区退出后,调用释放的命令,标记位复位。这期间,如果另一个进程准备进入同一个临界区,就要通过忙等待(busy waiting)来检测锁的标记为是否为零。只有为零的时候,才可以继续执行。这样的锁称为旋转锁(spin lock)。
忙等待的代码大致是:

while (flag) {}
这种办法有两个问题:第一个是正确性。设想有一段程序是这样的:
lock();
while (flag) {}
flag = 1;
//Go into the critical section…
首先尝试获得锁。如果这个锁目前空闲(标记位为0),那么该线程成功获得该锁,忙等待的while循环跳过,直接设置标记位为1,此时开始其它线程将不能进入该临界区,临界区只能由获得相应的锁的线程执行。
但是,如果在线程1把标记为设为1之前,调度器就将其暂停并切换了另一个线程,而另一个线程又能在不被打断的情况下成功设置标记位为1并开始执行,那么当再次切换到线程1的时候,由于忙等待已经跳过,线程1实际上也可以进入同一个临界区执行。因此,纯软件方式实现的锁实际上连互斥都不能满足,因为上锁过程可能会被调度器打断。
而且,忙等待会重复检测标记位,其对处理器的性能影响较大。并且,如果两个线程在同一个核心上轮流跑,而其中一个线程在忙等待另一个线程,那么被等待的这个进程反而还无法运行,直到下一次上下文切换。
很明显,这个实现方法是行不通的,必须换用其它方法。

1960年代的多处理器系统就具有了对锁的硬件支持。现在,所有计算机都具备这种支持。只有软硬结合,才能解决软件实现的锁的正确性问题。
Test-and-set是硬件支持的锁机制。该指令原子性地将指定变量赋予新值,并返回被赋值变量原有的值。使用它可以实现锁:当一线程尝试获得一个锁时,检测该锁是否空闲(空闲返回0,锁住返回1)。如果空闲,则这个锁会同时被占用。在硬件的支持下,检测一个锁是否为空闲和将空闲的锁占用这两个动作合并成了原子操作,不能被调度器中止。
我们用第8点给出的评价锁的三个原则来评估test-and-set机制是否正确且高效:
(1)正确性。Test-and-set只允许单个线程访问临界区,而且上锁过程不会被调度器打断。

你也许会问:如果解锁过程被打断,会不会影响锁的互斥性?答案是:不会。因为解锁过程被打断以后,锁并没有被成功解开。其它需要进入临界区的线程尝试访问临界区时,依然会被阻止。
(2)公平性。如果只是简单实现旋转锁,那么其实并不能保证公平:一个线程获得锁以后,可能会一直执行,直到执行完毕。
(3)性能。假设计算机是单核的,CPU时间会被严重浪费:因为当一个线程在占用临界区时,如果调度器切换了另一个尝试访问临界区的线程,那么这个线程在该时间片内只能忙等待,这个时间片就被浪费了。不过,如果计算机是多核的,且请求获得锁的线程数小于等于CPU的核心数,那么正在一个核心上访问临界区的线程与其它尝试获得锁的线程可以分配到不同的CPU核心上。虽然正在等待的线程依旧会浪费所在核心的CPU时间,但是至少没有拖累正在执行的线程的执行进度。
Test-and-set对应x86汇编中的xchg指令。C++11的头文件的std::atomic_flag提供了这个机制。

另一种硬件支持的机制与test-and-set有几分相像,称为CAS,即compare-and-swap(SPARC平台),在x86平台上称为compare-and-exchange。这个指令也是原子的,并需要三个参数:目标内存地址、期望值、新值。该指令检测特定值是否为期望的值。若是,则将其修改为指定的新值;若不是,不进行任何操作。可见,test-and-set指令总是会将指定变量替换为新值,而compare-and-swap只有在被检测的值与期望值相同时才进行赋值操作。笔者认为,test-and-set实际上叫做set-and-return或者return-and-set更合适。
如果线程通过CAS指令尝试获得锁,那么可以把地址参数设为锁的地址,并把期望值设为0,新值设为自己的TID(设为1亦可)。如果指定地址的值不是0,意味着有线程在占用临界区。如果值为零,那么代表没有线程占用临界区,该线程获得锁,并修改锁变量的值为自己的TID。
C++11的<atomic>头文件也提供了这个机制。

有的CPU提供一对指令来构建临界区。MIPS架构的CPU的这对指令被称为load-linked和store-conditional。Alpha、PowerPC、ARM等平台也提供相近的指令。
Load-linked的功能很像一般的Load指令:从内存中取得一个值,然后存入寄存器。但是store-conditional指令只有在刚才load-linked指令读取的地址的值未被更新的时候才执行成功(更新该地址的值为指定的值)。使用这对指令实现锁时,如果有两个线程先后尝试获得锁,那么其中一个线程会成功执行store-conditional指令获得锁,另一个线程的store-conditional指令则会失败。

Fetch-and-add机制与test-and-set类似:将目标地址的值自增(+1)的同时,返回自增之前的值。
Fetch-and-add可以实现一种新的锁——票锁(ticket lock)。当线程想要获得锁时,就尝试一次fetch-and-add操作,将这个锁的ticket value增加1。每个线程自己有一个票面值。当锁的票面值与线程的票面值相同时,这个线程进入临界区。需要解锁时,线程将锁的票面值再增加1即可。如果实现的是票锁,在获得锁之前,也会进行忙等待。

Fetch-and-add与缓存配合,可以实现锁的低延迟。
中定义了原子的fetch_add函数,GCC把它作为对C语言的扩展。除了fetch-and-add以外,类似的指令还有fetch-and-sub、fetch-and-and、fetch-and-or、fetch-and-xor等等。

上面介绍的实现锁的方法,都需要在尝试获得锁的线程未获得锁的时候进行忙等待,浪费CPU时间。有没有别的方法可以解决这个问题呢?答案是:让权等待。当线程尝试上锁失败后,立刻释放CPU,不要进行忙等待,让调度器调度其它的进程。让权等待是系统调用,调用的进程被从运行态设为就绪态,然后调度器选择另一个进程运行。这个过程叫做去调度(descheduling)。可以为票锁引入让权等待机制来改善性能。
但是,如果有几百个线程都想反复上一把锁,当一个线程成功上锁后,其它线程都会先后发现锁已经被占用,于是让出CPU。这会带来大量的上下文切换,开销依然很大。此外,一些线程也有可能总是得不到锁。

Linux使用futex(fast userspace mutex)来实现锁机制。在一个线程尝试获得锁失败后,会先忙等待一个比较短的时间;若仍然无法获得锁,则进入睡眠(sleeping)状态(睡眠的线程不会被调度器选择运行,不占用CPU),直到该线程尝试获得的锁变为空闲时才唤醒该线程。
这种两段锁(two-phase lock)方案混用了不同的方法。虽然如今上下文切换的代价已经很小,但实践表明,有许多时候往往只需要忙等待一个非常短的时间就能获得锁,这段时间小于上下文切换的开销。因此,先忙等待相当短的时间,看似浪费了CPU资源,但反而能带来性能的提升。生活中,混用不同的方案往往效果更好。混合制经济体制就是一个典例。

线程安全(thread safety),意味着当一个线程在修改为多个线程所共享的数据时,不会有其它线程可以修改这些共享的数据。

下面我们列举几个将单线程的数据结构改造为并发的数据结构的简单例子,来体会利用锁来将代码正确并发的可行方法和可能遇到的问题。
一个简易计数器的代码如下:

template class counter {
private:
_Ty value;
public:
counter() { value = 0; }
void increment() { ++value; }
void decrement() { --value; }
_Ty get() { return value; }
};
如何将锁应用到这个数据结构,来使其线程安全呢?一个最简单的想法是:当调用一个需要写入该数据结构的成员函数时,对需要修改的成员变量上锁,直到返回后再释放锁。
我们建立若干个线程,每个线程的任务都是一样的:给这个计数器增加同样多的计数。逐渐增加线程数,每个线程为计数器增加的计数不变,统计总的运行时间。
测试环境:Core i5-8400 2.80 GHz,Windows 10 Pro,Visual Studio 2019,Release x64
测试代码:
#include
#include
#include
#include

template class counter {
private:
_Ty value;
public:
counter() { value = 0; }
void increment() { ++value; }
void decrement() { --value; }
_Ty get() const { return value; }
};

const size_t thread_num = 2;

counter<> c; std::vectorstd::thread t;

inline void inc() {
for (unsigned i = 0; i < 1e8; ++i) c.increment();
}

int main() {
auto t0 = std::chrono::steady_clock::now().time_since_epoch().count();
for (size_t i = 0; i < thread_num; ++i) { t.emplace_back(std::thread(inc)); }
for (size_t i = 0; i < thread_num; ++i) { t[i].join(); }
auto t1 = std::chrono::steady_clock::now().time_since_epoch().count();
std::cout << "Duration = " << (t1 - t0) / 1e6 << "ms.\nThe value of the counter is " << c.get() << std::endl;
return 0;
}
测试结果:
The number of threads Time elapsed (ms) Total increment count
1 60.9282 108
2 71.5298 108
3 65.9102 108
4 103.584 108
5 90.2162 108
6 111.827 108
7 103.828 108
8 118.452 108
如果用多个线程同时增加计数器的值,不但最后总的增加次数还是108而不是随着线程数的增加而线性增加,而且计数器增加同样的值花费的总时间居然还变多了!
如果增加线程数的同时让总的计数器增加次数不变,结果又如何呢?
测试结果:
Number of threads Increment count of each thread Time elapsed (ms) Total increment count
1 108 68.8759 108
2 5×107 38.2004 108
4 2.5×107 31.0144 108
8 1.25×107 29.9844 108
可以看出,线程数与总的执行时间明显不是反比例关系,执行时间的缩短幅度显著低于预期。
我们回忆前面讲过的竞争条件。函数f执行的过程可能会被编译器优化成这样:先从内存中取得计数器类的实例c,然后把它的成员变量value放入寄存器中,对寄存器中的值增加指定的次数,再把增加完毕的value写回内存。但是在一个线程把增加后的值写入内存之前,其它线程也被创建并执行了——无论它们是在不同的核心上执行的,还是在同一个核心上因为上下文切换才开始执行的,它们从内存中读到的value的初值都是0,而不是先前开始的线程已经增加的值。所以,虽然各个线程实行的自增次数一次都没少,但是这之中有很多自增实际上做了无用功。大家可以自己试着做:如果在创建进程后添加适当的延时,那么可以让计数器总的增加次数与线程数成正比例关系。
还有没有别的方法可以实现呢?一个办法是:单个线程先在自己的局部变量中自增一定数量,自增完毕后把增加的值累加到作为全局变量的计数器中。当然,这种方法也对计数器的精度造成了损失(计数器的值的加减不再精确到1),并增加了其增减的延时。具体代码这里不给出,留作练习。

对于链表,或许最简单的方式就是让整个链表共享一把锁了。但这样的方法的并发度并不算高。可以给每个节点都放置一个锁。修改第一个节点时把锁锁住;修改完毕时,先把下一个节点的锁锁住,再释放当前节点的锁。不过,由于这种方法频繁对锁操作,开销也是十分大的;也许可以令多个节点共用一个锁。在将代码并行化的时候,如果需要用到锁机制,务必考虑对锁的访问是否过多。特别是对一些非常底层的代码,尤其是数据结构,能够优化一点,对总体性能的提升也许就非常明显,因为这些代码的使用频率非常高。

对于队列,处理就稍微简单些。因为队列只在头尾进行操作,所以把头结点和尾结点各设一把锁即可。在初始化队列的时候,放一个空节点,且这个空节点始终在队列里。这样在队列无有效节点或只有一个有效的节点的时候,仍然可以对头尾同时进行修改操作。具体的实现同样留作练习。

对于哈希表,并发就比较容易了,而且性能提升非常明显。哈希值相同的元素会被装在桶中,桶可以用链表或者平衡树实现。计算哈希的过程自然可以并发。只要是对不同哈希值的元素进行操作(添加、删除),修改哈希表的过程也可以很容易做到独立进行、互不干扰。

除了传统的并发数据结构以外,还有一个方向就是无锁数据结构(lock-free data structures)。但这并不是本书所能覆盖的知识面,感兴趣的读者可以自行研究。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值