线程及线程安全

                                          参考自《程序员的自我修养》

线程基础

什么是线程

线程是程序执行的最小单位。一个标准的线程由线程ID、当前指令指针PC、寄存器集合和堆栈组成。通常来说,一个进程有一个或多个线程组成。各个线程之间共享程序的内存空间(包括代码段、数据段、堆等)及一些进程级的资源。一个经典的线程和进程的关系如下图所示:


进程内的线程.jpg
多线程

大多数应用程序是多线程的,多个线程可以互不干扰地并发执行,并共享进程的全局变量和堆的数据。那么,什么情况下使用多线程呢?

  • 某个操作可能会陷入长时间等待,等待的线程会进入休眠状态,无法继续执行。多线程可以有效利用等待时间。
  • 某个操作会消耗大量时间,如果只有一个线程,程序和用户之间的交互会中断。多线程可以让一个线程负责交互,其他线程负责耗时操作。
  • 程序逻辑本身就要求并发,比如同时下载多个资源。
  • 多CPU或多核计算机,本身具备同时执行多个线程的能力,单线程程序无法全面地发挥计算机的全部计算能力。
  • 相对于多进程应用,多线程在数据共享方面效率要高很多。
线程访问权限

线程可以访问进程内存里的所有数据(包括全局变量、堆上的数据、函数里的静态变量、程序代码和代打开文件等),如果知道其他线程的堆栈地址,甚至也可以访问,但是在实际应用中线程也拥有自己的私有存储空间,包括以下几个方面:

  • 栈(尽管并非无法完全被其他线程访问,但一般情况下可以认为是私有数据)。
  • 线程局部存储(Tread Local Storage, TLS)。线程局部存储是某些操作系统为线程单独提供的私有空间,但通常具有有限的容量。
  • 寄存器(包括PC寄存器),寄存器是执行流的基本数据,因此为线程私有。
线程调度和优先级

当线程数量小于处理器储量(操作系统支持多处理器),线程的并发是真正的并发。但对于线程数量大于处理器的数量,线程并发会受到一定的阻碍,因此此时至少由一个处理器会运行多个线程。一个处理器运行多个线程,操作系统会让这些多线程程序轮流执行,每次仅执行一小时间(通常几十到几百毫秒),这样每个线程就看起来在同时执行。这样一个不断在处理器上切换线程的行为称之为线程调度(Thread Schedule)。在线程调度中通常有至少三种状态,分别是:

  • 运行(Running):此时线程正在执行。
  • 就绪(Ready):此时线程可以立刻执行,但是CPU已经被占用。
  • 等待(Waiting):此时线程正在等待某一个事件(通常是I/O或同步)发生,无法执行。
    处于运行中的线程拥有一段可以执行的时间,这段时间成为时间片(Time Slice),当时间片用尽,该线程就进入就绪状态。如果在时间片用尽之前进程就开始等待某个事件,那么它将进入等待状态。每当一个线程离开运行状态时,调度系统就会选择其他就绪的线程继续执行。在一个处于等待状态的线程所等待的事件发生后,该线程会进入就绪状态。这三种状态流转图如下:
    三种状态流转图.jpg

线程安全

前面我们知道,多线程程序中,多线程共享进程空间。进程中的全局变量和堆数据随时都可能被其他线程修改,因此多线程的程序中在并发时保持数据的一致性非常重要。这就引出了线程安全问题得讨论。

竞争与原子操作

多个线程同时访问一个共享数据,就有可能造成恶劣的后果。就比如下面的一个例子:
假设有个变量i=1,线程1对i++:

i++

线程2对i--:

i--;

在很多体系结构中,i++的实现如下:

(1)读取i到某个寄存器X;
(2)X++;
(3)将X的内容存回i。

i--也是同理,假设i进程2的寄存器是X2。由于线程1和线程2并发执行,有可能会出现如下情况,当线程1进行(2)步还没到(3)步时,这时候线程2进来,也取走i存入X2,并进行X2--,而此时i依然是i=1,X=2,X2=0,接下来线程1进行(3)步骤,此时i=X=2,但是这回线程2也来了,接着也进行1=X2的操作,此时i=0。而从代码逻辑上看,两个线程执行完毕之后i的值应该为1,但这里得到的是0,实际上两个线程同时执行的话,i的结果可能是0,也可能是1或2。

很明显,自增(++)操作在多线程环境下会出现错误是因为这个操作被编译为汇编代码之后不止一条指令,因此在执行的时候可能执行一半就被系统打断,去执行别的代码。我们把单指令的操作成为原子操作(Atomic),因为无论如何,单条指令的执行是不会被打断的。

原子操作虽然一定程度上能保证线程安全。但是原子操作只适用于比较简单的场合,在复杂的场合下,比如我们要保证一个复杂的数据结构更改的原子性,原子操作就显得力不从心了。这里就可以用到通用的手段:锁。

同步与锁

为了避免多个线程同时读写一个数据而产生不可预料的后果,我们要将各个线程对同一个数据的访问同步(Synchronization)。所谓同步,就是指一个线程访问数据未结束的时候,其他线程不得对同一个数据进行访问。如此,对数据的访问被原子化了。

同步最常用的方式是使用锁(Lock)。锁是一种非常强的机制,每一个线程在访问数据或资源之前首先试图获取锁,并在访问结束之后释放锁。在锁已经被占的时候试图获取锁时,线程会等待,直到锁被其他线程释放重新可用。常用的锁有如下几种:

  • 二元信号量(Binary Semaphore):是一种最简单的锁,它有两种状态:占用与非占用。它适合只能被唯一一线程独占访问的资源。当二元信号量处于非占用状态时,第一个试图获取该二元信号量的线程会获得该锁,并将二元信号量置为占用状态,此时其他的所有试图获取该二元信号量的线程将会等待,直到该锁被释放。
  • 信号量(Semphore):信号量即多元信号量,对于允许多个线程并发访问的资源,它是一个很好的选择。一个初始值为N的信号量允许N个线程并发访问。线程访问资源时首先获取信号量,进行如下操作:

(1)将信号量的值减1;
(2)如果信号量的值小于0,则进入等待状态,否则继续执行。访问资源之后,线程释放信号量,进行如下操作。
(3)将信号量的值加1。
(4)如果信号量的值小于1,唤醒一个等待中的线程。

  • 互斥量(Mutex):和二元信号量很类似,资源仅同时允许一个线程访问,但和信号量不同的是,信号量在整个系统可以被任意线程获取并释放,也就是说,同一个信号量可以被系统中的一个线程获取之后有另一个线程释放。而互斥量则要求哪个线程获取互斥量,哪个线程就要负责释放这个锁,其他线程越俎代庖去释放互斥量是无效的
  • 临界区(Critical Section):是比互斥量更加严格的手段。在术语中,把临界区的锁的获取称为进入临界区,把锁的释放称为离开临界区。临界区和互斥量与信号量的区别在于,互斥量和信号量在任何进程里都是可见的,也就是说,一个进程创建了互斥量或信号量,另一个进程试图去获取该锁是合法的。然而,临界区的作用范围仅限于本线程,其他的进程无法获取该锁。除此之外,临界区和互斥量具有相同的性质。
  • 读写锁(Read-Write Lock):致力于一种更加特定的场合的使同步。对于一段数据,多个线程同时读取总是没有问题得,但是假设操作都不是原子型,只要有任何一个线程试图对这个数据进行修改,就必须使用同步手段来避免出错。如果我们使用上述的信号量、互斥量和临界区中的任何一种来进行同步,尽管可以保证程序正确,但是对于读取频繁,而仅仅偶尔写入的情况,会显得非常低效,读写锁可以避免这个问题。对于同一个锁,读写锁有两种获取方式,共享的独占的。当锁处于自由状态时,试图以任何一种方式获取锁都能成功,并将锁置于对应的装填;当锁属于共享状态时,其他线程以共享的方式获取锁仍然会成功,此时锁被分配给了多个线程;然而,如果其他线程试图以独占的方式过去属于共享状态的锁,那么它将必须等待锁被所有的线程释放。相应地,处于独占状态的锁将阻止任何其他线程获取该锁,不论它们试图以哪种方式获取。
  • 条件变量(Condition Variable):作为一种同步手段,作用类似于一个栅栏。对于条件变量,线程可以有两种操作,首先线程可以等待条件变量,一个条件变量可以被多个线程等待;其次,线程可以唤醒条件变量,此时某个或者所有等待此条件变量的线程会被唤醒并继续执行。也就是说,使用条件可以让许多线程一起等待某个事件的发生,当事件发生时(条件变量被唤醒),所有线程可以一起回复执行。
可重入(Reentrant)与线程安全

一个函数被重入,表示这个函数没有执行完成,由于外部因素或内部调用,又一次进入该函数执行。一个函数要被重入,只有两种情况:
(1)多个线程执行这个函数。
(2)函数自身调用自身(比如递归调用)。
一个函数被称为可重入的,表明该函数被重入之后不会产生任何不良后果。举个例子,如下这个函数:

int add(int x){
    return x + x;
}

一个函数要成为可重入的,必须由如下几个特点:

  • 不使用任何(局部)静态变量或全局的非const变量。
  • 不返回任何(局部)静态变量或全局的非const变量的指针。
  • 仅依赖于调用方提供的参数。
  • 不依赖任何单个资源的锁(mutex等)。
  • 不调用任何不可重入的函数。
    可重入是并发安全的重要保障,一个可重入函数可以在多线程环境下放心使用。

三种多线程模型

线程的并发执行时多处理器或者操作系统调度来实现的。大多数操作系统,内核线程有多处理器或调度来实现并发。然而用户实际使用的线程并不是内核线程,而是存在于用户态的用户线程。用户态的线程并不一定在操作系统内核里有对应同等数量的内核线程,,对用户来说如果有三个线程同时执行,对内核来说很可能只有一个线程。

一对一模型

一个用户使用的线程唯一对应一个内核使用的线程(反过来不一定)。这样用户线程就具有了和内核线程一致的优点。

  • 优点:线程之间的并发是真正的并发,一个线程因为某种原因阻塞时,其他线程不会受到影响;一对一模型也可以让多线程程序在多处理器的系统上有更好的表现。
  • 缺点:由于操作系统限制了内核线程的数量,因此一对一线程模型会让用户线程数量受到限制;许多操作系统调度时,上下文切换的开销较大,导致用户线程的执行效率下降。
多对一模型

多对一模型将多个用户线程映射到一个内核线程上,线程间的切换由用户态的代码来进行。因此相对于一对一模型,多对一模型的线程切换要快得多。

  • 优点:多对一模型的好处是高效的上下文切换和几乎无限制的用户态线程数量。
  • 缺点:多对一模型的一个大问题是,如果一个用户线程阻塞,那么其他线程都将无法执行,因为此时内核里的线程也随之阻塞了;另外,在多处理系统上,处理器的增多,对于多对一模型的线程性能也不会有明显的帮助,不能充分利用多处理的优势。
多对多模型

多对多模型结合了多对一模型和一对一模型的特点,将多个用户线程映射到少数但不止一个内核线程上。在多对多模型中,一个线程阻塞不会使得所有线程的用户线程阻塞,因为此时还有别的线程可以被调度来执行。另外,多对多模型对用户线程的数量也没什么限制,在多处理器系统上,多对多模型的线程也能得到一定的性能提升,不过提升的幅度不如一对一模型的高。

你可能感兴趣的:(线程及线程安全)