参考: 《程序员的自我修养 -链接,装载与库》
进程与线程 - 浅浅念 - 博客园 (cnblogs.com)
线程: 也称作轻量级进程(Lightweight Process, LWP),是程序执行流的最小单元。
一个标准的线程由线程ID, 当前指令指针(PC),寄存器集合和堆栈组成。通常意义上,一个进程由一个到多个线程组成,各个线程之间共享程序的内存空间(包括代码段,数据段,堆等)及一些进程级的资源(如打开文件和信号)。一个经典的线程与进程的关系如下图所示:
线程的访问权限:
线程的访问非常自由,它可以访问进程内存里的所有数据,甚至包括其他线程的堆栈,但实际运用中线程也拥有自己的私有存储空间,包括以下几个方面
1. 栈: 尽管并非完全无法被其他线程访问,但一般情况下仍然可以认为是私有的数据
2. 线程局部存储:线程局部存储是某些操作系统未线程单独提供的私有空间,但通常只具有很有限的容量
3. 寄存器(包括PC寄存器) : 寄存器是执行流的基本数据,因此为线程私有
线程调度与优先级:
不论是在多处理器的计算机还是在单处理器的计算机上,线程总是“并发”执行的。
当线程数量小于等于处理器数量时(并且操作系统支持多处理器),线程的并发是真正的并发,不同的线程运行在不同的处理器上,彼此之间互不相干;但对于线程数量大于处理器数量的情况,线程的并发会受到一些阻碍,因此此时至少有一个处理器会运行多个线程。
在单处理器对应多线程的情况下,并发是一种模拟出来的概念。操作系统会让这些多线程程序轮流执行,每次仅执行一小段时间,这样每个线程就“看起来”在同时执行。这样的一个不断在处理器上切换不同的线程的行为称之为 线程调度(Thread Schedule)。
现在主流的调度方式尽管各不相同,但都带有 优先级调度 和 轮转法 的痕迹。在具有优先级调度的系统中,线程都拥有各自的 线程优先级(Thread Priority) 。具有高优先级的线程会更早的执行,而低优先级的线程常常要等待到系统中已经没有高优先级的可执行的线程存在时才能够执行。
通常情况下,我们一般把频繁等待的线程称之为 IO密集型线程(IO Bound Thread),而把很少等待的线程成为 CPU密集型线程(CPU Bound Thread)。IO密集型线程总是比CPU密集型线程个更容易得到优先级的提升
线程调度有一个特点,那就是线程在用尽时间片之后会被强制剥夺继续执行的权利。而进入就绪状态,这个过程叫做 抢占(Preemption),即之后执行的别的线程抢占了当前线程。多个线程同时访问一个共享数据,可能就会造成很严重的后果。
线程安全:
多线程程序处于一个多变的环境当中,可访问的全局变量和堆数据随时都可能被其他的线程改变
因此多线程程序在并发时数据的一致性变得非常重要。
# 竞争与原子操作:
我们把单指令的操作称为原子的(Atomic), 比方说自增操作(++)在多线程环境下会出现错误是因为这个操作被编译为汇编代码后不止一条指令,因此在执行的时候可能执行了一半就被调度系统打断,去执行别的代码。而单条指令的执行是不会被打断的。
# 同步与锁:
同步,是指在一个线程访问数据未结束的时候,其他线程不得对同一个数据进行访问。
同步的最常见方法是使用 锁(Lock)。锁是一种非强制机制,每一个线程在访问数据或资源之前首先试图 获取(Acquire) 锁,并在访问结束之后 释放(Release)锁。在锁已经被占用的时候试图获取锁时,线程会等待,直到锁重新可用。
二元信号量(Binary Semaphore)是最简单的一种锁,它只有两种状态: 占用与非占用。它适合只能被唯一一个线程独占访问的资源。
互斥量(Mutex)和二元信号量类似,资源仅同时允许一个线程访问。但不同的是,信号量在整个系统中可以被任意线程获取并释放(同一个信号量可以被系统中的一个线程获取之后由另一个线程释放),而互斥量则要求哪个线程获取了互斥量,哪个线程就要负责释放这个锁。
临界区(Critical Section)是比互斥量更加严格的同步手段。临界区和互斥量与信号量的区别在于,互斥量和信号量在系统的 任意进程 里都是可见的(一个进程创建了一个互斥量或信号量,另一个 进程 试图去获取该锁是合法的)。然而临界区的作用范围仅限于 本进程。其他的进程无法获取该锁,除此之外,临界区具有和互斥量相同的性质
读写锁(Read-Write Lock)致力于一种更加特定的场合的同步。对于同一个锁,读写锁有两种获取方式,共享的(Shared)或 独占的(Exclusive)。当锁处于自由的状态时,试图以任何一种方式获取锁都能成功,并将锁置于对应的状态。如果锁处于共享状态,其他线程以共享的方式获取锁仍然会成功过,此时这个锁分配给了多个线程。然而,如果其他线程试图以独占的方式获取已经处于共享状态的锁,那么它将必须等待锁被所有的线程释放。相应的,处于独占状态的锁将阻止任何其他线程获取该锁,不论它们试图以哪种方式获取。读写锁的行为如下表:
读写锁状态 | 以共享方式获取 | 以独占方式获取 |
自由 | 成功 | 成功 |
共享 | 成功 | 等待 |
独占 | 等待 | 等待 |
条件变量(Condition Variable)作为一种同步手段,作用类似于一个栅栏。使用条件变量可以让许多线程一起等待某个事件的发生,当事件发生时(条件变量被唤醒),所有的线程可以一起恢复执行。一个条件变量可以被多个线程等待,其次,线程可以唤醒条件变量
# 可重入与线程安全:
看一段代码: 多线程环境下创建单例
volatile T* pInst = 0; T* GetInstance() { if (pInst == NULL) { lock(); if (pInst == NULL) pInst = new T; unlock(); } return pInst; }
假设有两个线程 a, b同时访问 GetInstance(),线程 a 和 b 同时进入第一个判断语句,然后 a、b又要lock(), 但是 lock 只允许一个线程进入,假设 a进入了,创建了实例,解锁,然后唤醒 b,这时 b第二个条件通过不了, 不再创建实例; (参考: 关于双重if的妙用问题。大家一起讨论讨论。。-CSDN社区)
另一种情况是指令顺序的调整在多线程环境下也可能会带来问题。仍以上面代码为例(引自书籍)
C++里的 new 其实包含了两个步骤:
1. 分配内存
2. 调用构造函数
所以 pInst = new T 包含了三个步骤
1. 分配内存
2. 在内存的位置上调用构造函数
3. 将内存的地址赋值给 pInst
在这三步中,2,3的顺序是可以颠倒的。也就是说,完全有可能出现这种情况:pInst 的值已经不是 NULL,但对象仍然没有构造完毕,这时候如果出现另一个对 GetInstance 的并发调用,此时第一个 If 内的表达式 pInst == NULL 为 false, 所以这个调用会直接返回尚未构造完成的对象的地址 (pInst) 以提供给用户使用。
CPU的乱序执行能力让我们对多线程的安全保障的努力变得异常困难。因此,阻止 CPU 换序是必要的。通常情况下是调用 CPU 提供的一条指令,这条指令常常被称为 barrier, 一条 barrier 指令会阻止CPU将该指令之前的指令交换到 barrier之后;barrier 指令的作用类似于一个拦水坝,阻止换序穿透这个大坝。例如
#define barrier() __asm__ volatile ("lwsync") volatile T* pInst = 0; T* GetInstance() { if (!pInst) { lock(); if (!pInst) T* temp = new T; barrier(); pInst = new T; unlock(); } return pInst; }
由于barrier的存在,对象的构造一定在 barrier 执行之前完成,因此 pInst 被赋值时对象总是完好的。