第一章——线程的介绍
1 什么是线程
线程,有时被称为轻量级进程,是程序执行的最小单元。一个标准的线程由线程ID、程序计数器(pc)、一组寄存器和堆栈组成。通常,一个进程由多个线程组成,每个线程之间共享进程的内存空间(包括代码段、数据段、堆等)及一些进程级的资源(如打开的文件描述符和信号)。如下图所示:
2 线程的访问权限
线程的访问非常自由,它可以访问进程内存里的所有数据,同时线程也拥有自己IDE私有存储空间,包括以下几方面:
1)栈
2)线程局部存储(TLS)。
3)寄存器(包括PC寄存器)
3 线程调度和优先级
在单处理器对应多线程的情况下,并发是一种模拟出来的。操作系统通过让多个线程轮流使用CPU,这样每个线程就“看起来”在同时执行。
在线程调度中,线程至少有三种状态,分别是:
1)运行:此时线程获得CPU正在执行
2)就绪:此时线程只有获得CPU就可以立刻执行
3)等待:此时线程正在等待某一事件发送,无法执行。
线程转换图:
4 Linux多线程
Linux对多线程的支持颇为贫乏,事实上,在Linux内核中并不存在真正意义上的线程概念。Linux将所有的执行实体(无论是线程还是进程)都称为任务,每一个任务类似于一个单线程的进程,具有内存空间、执行实体、文件资源等。
fork函数产生一个和当前进程完全一样的新进程,并和当前进程一样从fork函数里返回。
fork产生新任务的速度非常快,因为fork并不复制原任务的内存空间(这里指的是物理内存,父子进程的虚拟地址空间的独立的),而是和原任务一起共享一个写时复制(COW)的内存空间。所谓写时复制,指的是两个任务可以同时自由地读取内存,但任意一任务试图对内存进行修改时,内存就会复制一份提供给修改方单独使用,以免影响到其他的任务使用。
fork只能够产生本任务的镜像,如果要启动新任务,则使用exec。exec可以用新的可执行映像替换当前的可执行映像,因此在fork产生了一个新任务后,新任务可以exec来执行新的可执行文件。fork和exec都只用于产生新任务,而如果要产生新线程,则可以使用clone。
5 线程安全
多线程程序处于一个多变的环境中,可访问的全局变量和堆数据随时都可能被其他的线程改变。因此多线程程序在并发时数据的一致性变得非常重要。
5.1 竞争和原子操作
多个线程同时访问一个共享数据,可能造成错误的结果:
例如:
在许多体系结构上,++i的实现会如下:
1)读取i到某个寄存器X
2)X++
3)将X的内存存储回i
由于线程1和线程2的并发执行,因此两个线程的执行序列可能如下:
从程序的逻辑看,正确的结果应该是i为0.但是由于执行的序列问题,可能出现的结果有0,1,2。可见,两个线程同时操作一个共享数据会出现意想不到的结果。
很明显,这里出现错误的原因主要在于自增(++)操作被操作系统编译为汇编代码之后不止一条指令,因此在多线程环境下就可能出现执行了一半而被调度系统打断,去执行其他的代码。如果单条指令是原子的,则执行就不会被打断。问题是,尽管原子操作非常方便,但是它仅适用于比较简单的场合。
5.2 同步和锁
为了避免多个线程同时读写一个数据而出现不可预料的结果,我们需要将各种线程对同一数据的访问同步。所谓同步,即是指在一个线程访问数据未结束的时候,其他线程不得对同一个数据进行访问。
同步的最常见方法是加锁。锁是一种非强制机制,每一个线程在访问数据或资源之前首先试图获取锁,访问完后释放锁。
二元信号量是最简单的一种锁,它只有两种状态:占用和非占用。它适合只能被唯一一个线程访问的资源。
对于允许多个线程并发访问的资源,使用多元信号量。一个初始值为N的信号量允许N个线程并发访问。
互斥量和二元信号量类似。
临界区是一段访问临界资源的代码。临界区和互斥量和信号量的区别在于,互斥量和信号量在系统的任何进程都是可见的,也就是说,一个进程创建了一个互斥量或信号量,另一个进程试图去获取该锁是合法的。然而,临界区的作用仅限于同一进程内的不同线程之间的同步,不能用于进程的同步。
读写锁分为共享的和独占的。
条件变量,使用条件变量可以让许多线程一起等待某个事件的发生,当事件发生后,所有线程可以一起恢复。
6 可重入与线程安全
一个函数要成为可重入的,必须具有以下几个特点:
1)不使用任何(局部)静态或全局的非const变量
2)不返回任何(局部)静态或全局的非const变量的指针
3)仅依赖于调用方提供的参数
4)不依赖于单个资源的锁(mutex等)
5)不调用任何不可重入的函数
7 过度优化
有时候过度优化也会造成线程安全问题。
例如:
由于有锁的保护,x++的行为不会被并发所破坏,那么x似乎必然为2.然而,如果编译器为了提高x的访问速度,把x放入了某个寄存器中,那么我们知道不同线程的寄存器是各自独立的,此时就出现线程安全问题,例如:
可见,现在即使加锁也不能保证结果正确。
我们可以使用volatile关键字试图阻止过度优化。volatile可以阻止两件事情:
1)阻止编译器为了提高速度将一个变量缓存在寄存器内而不写回。
2)阻止编译器调整操作volatile变量的指令。