摘要:在计算机发展早期,CPU的资源十分昂贵,如果一个CPU只能运行一个程序,那么当读写磁盘是CPU就只能处于空闲状态,造成了极大的浪费。从而有了多道程序的方式,即编译一个监控程序,当程序不需要使用CPU时,将其他在等待CPU的程序启动。该方式大大提高了CPU的利用率,但它的弊端是不分轻重缓急,有时候一个交互操作可能要等待数十分钟。
分时系统:每个CPU运行一段时间后,就主动让出给其他CPU使用。Windows早期版本和Mac OS版本都是采用的这种分时系统来调度程序的。但是一旦有任何程序出现问题无法主动让出CPU给其它程序的话那么操作系统也没有办法,其它程序只能等待,造成死机假象。
多任务系统:操作系统接管了所有的硬件资源,并且本身运行在一个受硬件保护的级别。所有的应用都以进程的方式运行在比操作系统更低的级别,每个进程都有自己独立的地址空间,使得进程之间的地址空间相互隔离。CPU由操作系统进行同一分配,每个进程根据进程优先级的高低都有机会获得CPU,但如果运行超过一定的时间,CPU会将资源分配给其他进程,这种CPU分配方式是抢占式,操作系统可以强制剥夺CPU资源并且分配给它认为目前最需要的进程。如果操作系统分配每个进程的时间很短,就会造成很多进程都在同时运行的假象,即所谓的宏观并行,微观串行。
什么是线程
线程(Thread),有时被称为轻量级进程(Lightweight Process,LWP),是程序执行流的最小单元。一个标准的线程由线程ID、当前指令指针(PC)、寄存器集合和堆栈组成,通常意义上,一个进程由多个线程组成,各个线程之间共享程序的内存空间(代码段、数据段、堆等)及一些进程的资源。
多线程的优点
- 某个操作可能会陷入长时间等待,等待的线程会进入睡眠状态,无法继续执行。
- 某个操作会消耗大量的时间,如果只有一个线程,程序和用户之间的交互会中断。
- 程序逻辑本身就要求并发操作。
- 多CPU或多核计算机,本身具备同时执行多个线程的能力。
- 相对于多进程应用,多线程在数据共享方面效率要高很多。
线程的访问权限
线程的访问非常自由,它可以访问进程内存里所有数据,包括其他线程的堆栈(如果知道地址的话,情况很少见)。
线程自己的私用存储空间:
- 栈(并非完全无法被其他线程访问,但一般情况下仍然可以认为是私有的数据)
- 线程局部存储。某些操作系统为线程提供私用空间,但容量有限。
- 寄存器。执行流的基本数据,为线程私用。
线程调度与优先级
不论在多处理器还是单处理器上,线程都是“并发”的。线程数量小于处理器数量时,是真正并发的。单处理器下,并发是模拟的,操作系统会让这些多线程程序轮流执行,每次都只执行一小段时间,这就称为线程调度。
线程调度中,线程拥有三种状态:
- 运行:线程正在执行
- 线程可以立刻运行,但CPU被占用
- 线程正在等待某一事件发生,无法立即执行。
处于运行中的线程拥有一段可以执行的时间,这称为时间片,当时间片用尽的时候,进程进入就绪状态,如果在用尽之前开始等待某事件,那么它就进入等待状态。每当一个线程离开运行状态的时候,调度系统就会选择一个其他的就绪线程继续执行。
现在的主流调度方法尽管都不一样,但基本都带有优先级调度和轮转法。
轮转法:各个线程轮流执行一段时间。
优先级调度:按线程的优先级来轮流执行,每个线程都拥有各自的线程优先级。
在win和lin里面,线程优先级不仅可以由用户手动设置,系统还会根据不同线程表现自动调整优先级。
一般频繁等待的线程称之为IO密集型线程,而把很少等待的线程称为CPU密集型线程,IO密集型线程总是比CPU密集型线程容易得到优先级的提升。
优先级调度下,存在一种饿死现象。
饿死:线程优先级较低,在它执行之前,总是有较高级的线程要执行,所以,低优先级线程总是无法执行的。
当一个CPU密集型线程获得较高优先级时,许多低优先级线程就可能被饿死。
为了避免饿死,操作系统常常会逐步提升那些等待时间过长的线程。
在优先级调度的环境下,线程优先级改变一般有三种方式:
- 用户指定优先级
- 根据进入等待状态的频繁程度提升或降低优先级
- 长时间得不到执行而被提升优先级
可抢占线程和不可抢占线程
抢占:在线程用尽时间片之后被强制剥夺继续执行的权利,而进入就绪状态。
在早期的系统中,线程是不可抢占的。线程必须手动触发一个放弃执行的命令才能让其他的线程得到执行。这样的情况下线程必须主动进入就绪状态,而不是靠时间片佣金来杯强制进入。
在不可抢占线程中,线程主动放弃主要是2种:
- 当线程试图等待某个事件(I/O)时
- 线程主动放弃时间片
不可抢占线程有一个好处,就是线程调度只会发生在线程主动放弃执行或线程等待某个事件的时候,这样就可以避免一些抢占式线程时间不确定而产生的问题。
Linux的多线程
Linux内核中并不存在真正意义上的线程概念。Linux所有执行实体(线程和进程)都称为任务,每一个任务概念上都类似一个单线程的进程,具有内存空间,执行实体,文件资源等。Linux不同任务之间可以选择共享内存空间,相当于同一个内存空间的多个任务构成一个进程,这些任务就是进程中的线程。
系统调用 | 作用 |
---|---|
frok | 复制当前线程 |
exec | 使用新的可执行映像覆盖当前可执行映像 |
clone | 创建子进程并从指定位置开始执行 |
fork产生新任务速度非常快,因为fork不复制原任务的内存空间,而是和原任务一起共享一个写时复制的内存空间。
写时复制:两个任务可以同时自由地读取内存,但任意一个任务试图对内存进行修改时,内存就会复制一份提供给修改方单独使用,以免影响到其他的任务使用。
fork只能够产生本任务的镜像,因此需要和exec配合才能启动别的新任务。
而如果要产生新线程,则使用clone。
clone可以产生一个新的任务,从指定位置开始执行,并且共享当前进程的内存空间和文件等,实际效果就是产生一个线程。
线程安全
多线程程序处于一个多变的环境中,可访问的全局变量和堆数据随时都可能被其他的线程改变。因此多线程程序在并发时数据的一致性变得非常重要。
竞争与原子操作
多个线程同时访问同一个共享数据可能造成很恶劣的后果。学习线程常用的售票系统就是一个很好的例子。
单条指令的操作称为原子的,单挑指令的执行不会被打断。
同步与锁
为了防止多个线程读取同一个数据产生不可预料结果,我们将各个线程对一个数据的访问同步。即在一个线程对一个数据访问结束的时候,其他线程不能对同一个数据进行访问。对数据的访问被原子化。
同步最常见的方法是使用锁,锁是一种非强制机制,每一个线程在访问数据或者资源之前会先获取锁,在访问结束后会释放锁。在锁被占用时候试图获取锁时,线程会等待,直到锁可以重新使用。
二元信号量是最简单的一种锁,它的两种状态:占用与非占用,当二元信号量处于非占用状态下时,第一个视图获取该二元信号量的线程会获取该锁,并将二元信号量置位占用状态,此后其他视图获取该信号量的线程将会等待,直到该锁被释放。
信号量允许多个线程并发访问的资源,一个初始值为N的信号量允许N个线程并发访问。线程访问时首先要获取信号量。同一信号量可以被系统中的一个线程获取之后由另外一个线程释放。操作如下:
- 将信号量的值减1
- 如果信号量值小于0,就进入等待状态,否则继续执行。
访问完资源后,线程释放信号量:
- 将信号量的值+1
- 如果信号量的值小于1,唤醒一个等待中的线程。
互斥量和二元信号量很类似,但和信号量不同的是:信号量在一个系统中,可以被任意线程获取或释放。互斥量要求那个线程获取互斥量,那么哪个线程就释放互斥量,其他线程释放无效。
临界区比互斥量更加严格的手段。把临界区的锁获取称为进入临界区,而把锁的释放称为离开临界区。临界区和互斥量,信号量区别在与互斥量,信号量在系统中任意进程都是可见的。临界区的作用范围仅限于本线程,其他线程无法获取。其他性质与互斥量相同。
读写锁致力于一种更加特定的场合的同步。如果使用之前使用的信号量、互斥量或临界区中的任何一种进行同步,对于读取频繁,而仅仅是偶尔写入的情况会显得非常低效。读写锁可以避免这个问题。
条件变量作为同步的手段,作用类似于一个栅栏。对于条件变量,线程可以有两种操作,首先线程可以等待条件变量,一个条件变量可以被多个线程等待。其次,线程可以唤醒条件变量,此时某个活所有等待次条件变量的线程都会被唤醒并继续执行。
可重入与线程安全
一个函数被重入,表示这个函数没有执行完成,由于外部因素或内部调用,又一次进入该函数执行。一个函数要被重入,只有两种情况:
- 多个线程同时执行这个函数
- 函数自身(可能经过多层调用之后)调用自身
一个函数被称为可重入,表示重入之后不会产生任何不良影响,一个函数要成为可重入,必须具有如下特点:
- 不使用任何(局部)静态或全局的非const变量
- 不返回任何(局部)静态或全部的非const变量的指针
- 仅依赖调用方提供的参数
- 不依赖任何单个资源的锁
- 不调用任何不可重入的函数
可重入是并发安全的强力保障,一个可重入的函数可以在多程序环境下方向使用
过度优化
在日常开发中即使合理地使用了锁也不一定能保证线程安全,因为落后的编译器技术可能会导致看似无错的代码在优化和并发面前又产生了新的麻烦,如下:
x=0;
Thread1
lock();
x++;
unlock();
Thread2
lock();
x++;
unlock();
由于有锁的保护,x++的行为并不会被并发锁破坏,x的修改也为原子状态,所以结果不会出现错误。然而如果编译器为了提高x的访问速度把x放到某个寄存器总没有及时返回修改,而在此时另外一个线程对其进行操作并返回的话则会出现错误的情况:
- [Thread1]读取x的值到某个寄存器R [1] (R[1]=0);
- [Thread1]R[1]++(由于之后可能要访问到x,所以Thread1暂时不将R[1]写回x);
- [Thread2]读取x的值到某个寄存器R[2] (R[2]=0);
- [Thread2]R[2]++(R[2]=1);
- [Thread2]将R[2]写回至x(x=1);
- [Thread1] (很久以后)将R[1]写回至x(x=1);
在这样的情况下即使正确的加锁,也不能保证线程的安全。
x=y=0;
Thread1
x=1;
r1=y;
Thread2
y=1;
r2=x;
正常情况下r1和r2至少有一个为1,逻辑上不可能同时为0,然而实际上这种情况确实可能发生。由于cpu的动态调度功能,在执行程序的时候为了提高效率可能交换指令的顺序,同样编译器在优化的时候也可能为了效率而交换毫不相干的两条相邻指令(如x=1和r1=y)的执行顺序,所以以上代码执行的时候可能会这样的:
x=y=0;
Thread1
r1=y;
x=1;
Thread2
y=1;
r2=x;
我们可以通过voatile关键字阻止过度优化,voatile可以做两件事情:
- 阻止编译器为了提高速度将一个变量缓存到寄存器内而不写回
- 阻止编译器调整操作volatile变量的指令顺序
但是voatile关键字可以阻止编译器过度优化,却无法阻止CPU的动态调度。
多线程的内部情况
三种线程模型
线程的并发执行是由多处理器或操作系统调度来实现的。大多数操作系统,包括windows和linux都在内核中提供线程支持,内核线程由多处理器或调度来实现并发。然而对app开发者来说实际使用的线程并不是内核线程,而是存在于用户态的用户线程,用户态线程并不一定在操作系统内核里对应同等数量的内核线程。例如某些轻量级的线程库,对用户来说如果有三个线程同时执行,对内核来说很可能只有一个线程。
一对一模型
对于直接支持线程的系统,一对一模型始终是最为简单的模型。一个用户使用的线程就唯一对应一个内核使用的线程,但返回来,一个内核里面的线程在用户态不一定有对应的线程存在。
对于一对一模型,线程之间的并发是真正的并发,一个线程因为某个原因阻塞,并不会影响到其他线程。一对一模型也可以让多线程程序在多处理器的系统上有更好的表现。
一般直接使用API或者系统调用创建的线程均为一对一线程。
一对一线程的两个缺点:
- 由于许多操作系统限制了内核线程数量,因此一对一线程会让用户的线程数量受到限制。
- 许多操作系统内核线程调度是,上下文切换的开销较大,导致用户线程的执行效率下降。
多对一模型
多对一模型将多个用户线程映射到一个内核线程上,线程之间的切换由用户态的代码来进行,相对于一对一模型,多对一模型的线程切换要快速许多。
多对一模型的问题就是如果一个用户线程阻塞了,那么所有的线程都将无法执行。在多处理系统上,处理器的增多对多对一模型的线程性能不会有明显帮助。多对一模型得到的好处是高效的上下文切换和几乎无限制的线程数量。
多对多模型
多对多模型结合了多对一和一对一的特点,将多个用户线程映射到少数但不止一个内核线程上。一个用户线程阻塞并不会使得所有的用户线程阻塞。并且对用户线程数量也没有什么限制,在多处理器系统上,多对多模型的线程也能得到一定的性能提升,不过提升的幅度没有一对一模型高。
参考:
《程序员的自我修养》