本文最后更新于:2020年6月27日 晚上

* 第一章最后一部分,part.3。。。 *

第一章 温故而知新

1.6 众人拾柴火焰高

1.6.1 线程基础

1.线程(Thread),也称轻量级进程(Lightweight Process,LWP),是程序执行流的最小单元。一个标准线程由线程ID,当前指令指针(PC),寄存器集合和堆栈组成。进程由一个或多个线程组成,各线程之间共享(全局变量)代码段、数据段、堆、打开文件描述符、信号等。

进程内的线程

2.线程拥有的私有空间

  • 线程栈

  • 线程局部存储(Thread Local Storage,TLS),某些操作系统为线程单独提供有限容量的私有空间。

  • 寄存器(包括PC寄存器),是执行流的的基本数据,为线程私有。

线程私有 线程共享(进程所有)
局部变量 全局变量
函数的参数 堆数据
TLS数据 函数中的静态变量
程序代码
打开的文件,A线程打开的文件,B线程可以读写

3.当线程数量小于等于处理器数量时,才是真正的线程并发,不同线程运行在不同的处理器上。当单处理器对应多线程时,并发是模拟出来的状态,操作系统让多线程程序轮流执行。一个不断在处理器上切换不同状态的线程的行为成为线程调度(Thread Schedule)

  • 运行(Running):此时线程正在执行。

  • 就绪(Ready):此时线程可以立刻运行,但CPU已被占用。

  • 等待(Waiting):此时线程正在等待某一事件(比如I/O或同步)发生,无法执行。

处于运行中的线程拥有一段可以执行的时间,这段时间被称为时间片(Time Slice)

  • 如果时间片用尽时,该线程进入就绪状态。

  • 如果在时间片用尽之前线程就开始等待某事件,该线程进入等待状态。

  • 当一个线程离开运行状态时,调度系统选择一个就绪线程继续执行。

  • 当处于等待状态的线程所等待的事件发生后,该线程进入就绪状态。

线程状态切换

4.线程调度的方法中都带有优先级调度(Priority Schedule)轮转法(Round Robin)的痕迹。

  • 轮转法:各个线程轮流执行一小段时间。

  • 优先级调度:线程拥有各自的线程优先级(Thread Priority)线程按照优先级顺序轮流执行。

CPU也喜欢先捏软柿子。

系统会根据线程的表现自动调整优先级。频繁地进入等待状态(会放弃之后剩余的可占用时间份额)的线程(例如处理I/O的线程)称为IO密集型线程(IO Bound Thread);频繁地进行大量运算,以至于每次都把时间片全部用尽的线程称为CPU密集型线程(CPU Bound Thread)IO密集型线程总比CPU密集型线程容易得到优先级提升。

5.饿死(Starvation)现象:一个线程优先级较低,在它执行之前总是有优先级较高的线程要执行,因此这个低优先级线程始终无法执行。为了避免饿死现象,调度系统常常会逐步提升等待了过长时间得不到执行的线程的优先级

改变线程优先级一般的三种方式:

  • 用户指定优先级

  • 根据进入等待状态的频繁程度而提升或降低优先级

  • 长时间得不到执行而被提升优先级

6.线程在用尽时间片之后会被强制剥夺继续执行的权利,进入就绪状态,这个过程叫做抢占(Preemption),即之后执行的别的线程抢占了当前线程。早期系统中线程是不可抢占的。线程必须手动发出放弃执行的命令,才能让其他线程得到执行。此时线程是主动进入就绪状态的,而不是靠时间片用尽被强制进入。若线程始终拒绝进入就绪状态也不进行等待,那么其他线程将永远无法执行。但此时线程调度的时机是确定的。

在不可抢占线程中,线程主动放弃执行的情况:

  • 党线程试图等待某事件时(I/O等)

  • 线程主动放弃时间片

7.Linux内核中并不存在正真意义上的线程概念。Linux将所有执行实体都称为任务(Task),每一个任务概念上都类似于一个单线程,具有内存空间、执行实体、文件资源。共享同一个内存空间的多个任务构成了一个进程。

系统调用 作用
fork 复制当前进程
exec 使用新的可执行映像覆盖当前可执行映像
clone 创建子进程并从指定位置开始执行

fork函数产生一个和当前进程完全一样的新进程,并和当前进程一同从fork函数返回。

pid_t pid;
if(pid == fork())
{
	...
}
  • 调用fork函数后,新的任务启动并和本任务一起从fork函数返回。本任务fork返回新任务的pid,新任务fork返回0

  • fork不复制原任务的内存空间,而是和原任务一起共享一个写时复制/写时拷贝(Copy on Write,COW)的内存空间。写时复制指的是两个任务可以同时自由读取内存,但任意一个任务试图对内存进行修改时,内存就会复制一份提供给修改方单独使用,避免影响到其他任务使用。

  • exec可以用新的可执行映像替换当前的可执行影响,所以在fork产生一个新的任务之后,新任务调用exec来执行新的可执行文件。

  • clone可以产生一个新任,从指定位置开始执行,可选共享当前进程的内存空间和文件等

int clone(int (*fn)(void*), void* child_stack, int flags, void* arg);

写时复制

1.6.2 线程安全

1.在多线程环境中,可访问的全局变量和堆数据随时都可能被其他的线程改变。多线程程序在并发时数据的一致性非常重要。

2.单指令的操作称为原子的(Atomic),单条指令的执行是不会被打断的。例如自增(++)会被操作系统编译为汇编代码后不止一条指令,所以有可能执行一半就被调度系统打断,去执行别的代码,这不是线程安全的。

Windows API 作用
InterlockedExchange 原子地交换两个值
InterlockedDecrement 原子地减少一个值
InterlockedIncrement 原子地增加一个值
InterlockedXor 原子地进行异或操作

3.同步是指在一个线程访问数据未结束的时候。其他线程不得对同一个数据进行访问,如此对数据的访问被原子化了。

4.同步最常用的方法是使用锁(Lock)。锁是一种非强制机制,每一个线程在访问数据资源之前需试图获取(Acquire)锁,并在访问结束之后释放(Release)锁。在锁已经被占用的时候试图获取锁,线程会等待,直到锁被重新可用。

5.二元信号量(Binary Semaphore)是最简单的锁,有占用与非占用两种状态。它适合只能被唯一一个线程独占访问的资源。对于允许多个线程并发访问的资源,多元信号量简称信号量(Semaphore)一个初始值为N的信号量允许N个线程并发访问。线程访问资源的时候对信号量的操作步骤如下:

  • 将信号量的值减1
  • 如果信号量的值小于0,则进入等待状态,否则继续执行。访问完资源后,线程释放信号量
  • 将信号量的值加1
  • 如果信号量的值小于1,唤醒一个等待中的线程

6.互斥量(Mutex)和二元信号量类似,资源同时允许一个线程访问。信号量在整个系统中可以被任意线程获取或释放,即同一个信号量可以被系统中的任意一个线程获取之后由另一个线程释放。互斥量要求哪个线程获取互斥量,哪个线程负责释放互斥量。

7.临界区(Critical Section)是比互斥量更加严格的同步手段。把临界区的锁获取称为进入临界区,锁的释放称为离开临界区。互斥量和信号量在系统的任何进程里都是可见的,即一个进程创建的互斥量或信号量,另一个进程试图去获取该锁是合法的。临界区作用范围仅限于本进程,其他进程无法获取该锁。

8.读写锁(Read-Write Lock)是一种特定的场合的同步。对于同一个锁,读写锁有共享的(Shard)独占的(Exclusive)两种获取方式。

  • 当锁处于自由状态时,试图以任何一种方式获取锁都能成功,并将锁置于对应状态。

  • 如果锁处于共享状态,其他线程以共享的方式获取锁仍会成功,此时锁分配给多个线程。

  • 如果其他线程试图以独占的方式获取已经处于共享状态的锁,那么它必须等待锁被所有的线程释放。

  • 处于独占的锁阻止任何其他线程获取该锁。

读写锁状态 以共享方式获取 以独占方式获取
自由 成功 成功
共享 成功 等待
独占 等待 等待

9.条件变量(Condition Variable)类似于一个栅栏。

  • 线程可以等待条件变量,一个条件变量可以可以被多个线程等待

  • 线程可以唤醒条件变量,此时某个或所有等待此条件的变量的线程都会被唤醒并继续支持

未完待续,下次继续补在后面。。。

=================================================

继续。。。

10.一个函数被重入,表示这个函数没有执行完成,由于外部因素或内部调用,又一次进入该函数执行。只有两种情况:

  • 多个线程同时执行这个函数
  • 函数自身调用自身(递归调用)

一个函数是可重入的(Reentrant),说明该函数被重入之后不会产生任何不良后果,可以在多线程环境中安全使用。如要成为可重入,需有以下特点:

  • 不使用任何(局部)静态全局的非const变量
  • 不返回任何(局部)静态全局的非const变量的指针
  • 仅依赖于调用方提供的参数
  • 不依赖于任何单个资源的锁(mutex等)
  • 不调用任何不可重入的函数

11.过度优化:编译器进行优化时,有可能为了提高变量的访问速度,把变量放到某个寄存器中却不写回;也有可能为了效率而交换毫不相干的两条相邻指令。

使用volatile关键字试图阻止编译器过度优化。

  • 阻值编译器为了提高速度将一个变量缓存到寄存器内而不写回
  • 阻值编译器调整操作volatile变量的指令顺序

CPU进行动态调度时,在执行程序时为了提高效率可能交换指令的顺序。

/*
C++中new包含两个步骤:(1)分配内存、(2)调用析构函数
pInst = new T 包含三个步骤:(1)分配内存、(2)在内存的位置上调用构造函数、(3)将内存的地址赋值给pInst,(2)(3)顺序是可以颠倒的。
*/

volatile T *pInst = 0;
T* GetInstance()
{
	if(pInst == NULL)
	{
		lock();
		if(pInst == NULL)
			pInst = new T;
		unlock();
	}
	return pInst;
}

通常情况下调用CPU提供的一条指令,通常被称为barrier,一条barrier指令会阻值CPU将该指令之前的指令交换到barrier之后。

if(!pInst)
{
	T *temp = new T;
	barrier();
	pInst = temp;
}

1.6.3 多线程内部情况

1.线程的并发执行是由多处理器或操作系统调度来实现的。用户实际使用的线程并不是内核线程,而是存在于用户态的用户线程。用户态线程并不一定在操作系统内核中对应同等数量的内核线程。

  • 一对一模型:一个用户使用的线程唯一对应一个内核线程

一对一线程模型

优点:线程之间真正的并发,一个线程阻塞,其他线程执行不受影响。
缺点:操作系统限制了内核线程的数量,一对一模型的数量受到限制;线程调度时上下文切换开销大,导致用户线程执行效率低下。

  • 多对一模型多个用户线程映射到一个内核线程上,线程之间的切换由用户态的代码决定。

多对一线程模型

优点:高效的上下文切换和几乎无限制的线程数量。
缺点:如果一个用户线程阻塞,那么所有线程都无法得到执行,内核线程也随之阻塞。

  • 多对多模型:将多个用户线程映射到少数但不止一个内核线程上。

多对多线程模型

优点:一个用户线程阻塞不会使所有用户线程阻塞,因为此时还会有别的线程可以被调度执行。

* 终于完了。。。后面就不会跟着目录写了。。。 *