第一章:计算机的软硬件基本结构—6

第一章:计算机的软硬件基本结构—6

1.6 众人拾柴火焰高

1.6.1 线程基础

在现代软件系统中,线程和进程一样重要。特别是随着CPU频率增长开始出现停滞,而开始向多核发展。多线程,作为实现软件并发执行的一个重要的方法,也开始具有越来越重要的地位。

什么是线程

​ 线程(Thread),有时候被称为 轻量级进程(Lightweight Process,LWP),是程序执行流的最小单元,一个标准的线程由线程ID,当前指令指针(PC),寄存器集合和堆栈组成。

通常意义上,一个进程由一个到多个线程组成,各个线程间共享程序的内存空间(包括代码段,数据段,堆等)以及一些进程级的资源(打开文件和信号)

大多数软件应用中,线程数量都不只一个。多个线程可以互不干扰地并发执行,并共享进程的全局变量和堆的数据。和单线程对比,多线程具有的优势为:

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

线程的访问非常自由,它可以访问进程内存里的所有数据,甚至包括其他线程的堆栈,但实际运用中线程也拥有自己的私有存储空间,

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

无论是在多处理器的计算机还是在单处理器的计算机上,线程总是 **“并发”**执行的。

当线程数量小于等于处理器数量时(并且操作系统支持多处理器),线程的并发是真正的并发,不同线程运行在不同的处理器上,彼此之间互不相干。

但对于线程数量大于处理器数量的情况,线程的并发会受到一些阻碍,因为此时至少有一个处理器会运行多个线程。

在单处理器对应多个线程的情况下,并发是一种模拟出来的状态。操作系统会让这些多线程程序轮流执行,每次只执行一小段时间(通常是几十到几百毫秒),这样的每个线程就 **”看起来“**在同时执行。这样的一个不断在处理器上切换不同线程的行为称为 线程调度(Thread Schedule)

线程拥有至少三种状态:

  • 运行,此时线程正在执行。
  • 就绪,此时线程可以立刻运行,但CPU已经被占用。
  • 等待,此时线程正在等待某一事件(通常是I/O或同步)发生,无法执行。

处于运行中线程拥有一段可以执行的时间,这段时间被称为 时间片,当时间片用尽的时候,该进程将进入就绪状态。

线程调度自多任务操作系统问世以来就不断地被提出不同的方案和算法。现在主流的调度方式尽管不同,但是都带有 优先级调度和轮转法的影子。所谓轮转法,即是之前提到的让各个线程轮流执行一小段时间的方法,这决定了线程之间交错执行的特点。

在具有优先级调度的系统中,线程都拥有各自的 线程优先级.具有较高优先级的线程会更早地执行,而低优先级的线程常常要等待系统中以及没有高优先级的可以执行的线程存在时才能执行。

对于频繁进入等待状态的线程,比频繁进行大量计算,以至于每次都要把时间片用尽的线程要受欢迎得多。其实道理很简单,频繁进入等待的线程只占用很少的时间。我们一般把频繁进入等待的线程称之为IO密集型线程,而把很少进入等待的线程称为 CPU密集型线程。IO密集型线程总是比CPu密集型线程容易得到优先级提升。

在优先级调度下,线程的优先级改变有三种办法

  • 用户指定优先级
  • 根据进入等待状态的频繁程度提升或降低优先级。
  • 长时间得不到执行的而被提升优先级。
可抢占线程和不可抢占线程

我们讨论的线程调度有一个特点,那就是线程在时间片用尽之后会被强制剥夺继续执行的权利,而进入就绪状态,这个过程叫做 抢占,即之后执行的别的线程抢占了当前线程。在计算机早期,线程是不可以被抢占的,只能等线程主动放弃,主动放弃有两种情况:

  • 当线程试图等待某个事件时(I/O等)
  • 线程主动放弃时间片。

因此,在不可抢占式线程执行的时候,有一个显著的特点,那就是线程调度的时机是确定的,线程调度只会发生在线程主动放弃执行或线程等待某一事件时,这样可以避免一些因为抢占式线程里调度时机不确定而产生的问题。但是我们现在还是很少用非抢占线程。

Linux的多线程

对于Linux来说,线程并不是一个通用的概念。

Linux对多线程的支持颇为贫乏,Linux将所有执行的实体(无论是线程还是进程)都称之为 任务(Task),每一个任务概念上都类似一个单线程的进程。

Linux下,用以下办法可以创建一个新任务:

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

fork产生新任务的速度非常快,因为fork并不复制原任务的内存空间,而是和原任务一起共享一个 **写时复制(Copy on write,COW)**的内存空间,所谓的写时复制,指的是两个任务可以同时自由的读取内存,但任意一个任务试图对内存修改时,内存就会复制一份提供给修改方单独使用,以免影响到其他的任务使用。

fork只能产生本任务的镜像,因此必须要使用exec配合才能启动别的新任务。

1.6.2 线程安全

多线程处于一个多变的环境下,可访问的全局变量和堆数据随时都可能被其他的线程改变。因此多线程程序在并发时数据的一致性变得非常重要。

竞争与原子操作
多个线程同时访问一个共享数据,可能造成很恶劣的后果。

我们把单个指令的操作称为 原子的(Atomic),因为无论如何,单条指令的执行时不会被打断的。为了避免出错,很多体系结构都提供一些常用操作的原子指令。

Windos中有一套API专门进行原子操作。

使用这些函数时,windos将保证是原子操作的,因此不用担心出现问题。遗憾的是,尽管原子操作方便,但是他们仅仅适用于比较简单特定的场合,在复杂的场合下,比如我们要保证一个复杂的数据结构更改的原子性,我们就需要更加通用的手段: 锁。

同步与锁

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

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

二元信号量

是最简单的一种锁,它只有两种状态:占用与非占用。它适合只能被唯一一个线程独占访问的资源。当二元信号量处于非占用状态是时,第一个试图获取该二元信号量的线程会获得该锁。

互斥量(Mutex)

和二元信号量很类似,即资源仅同时允许一个线程访问,但和信号量不同的是,信号量在整个系统可以被任意线程获取并释放,也就是说,同一个信号量可以被系统中的一个线程获取之后由另一个线程释放。而互斥量则要求哪个线程获取了互斥量,哪个线程就要负责释放这个锁,其他线程越俎代庖去释放互斥量是无效的。

临界区(Critical Section)

是比互斥量更加严格的同步手段。在术语中,把临界区的锁的获取称为进入临界区,而把锁的释放称为离开临界区。临界区和互斥量与信号量的区别在于,互斥量和信号量在系统中任何进程里都是可见的,也就是说,一个进程创建了一个互斥量或信号量,另一个进程试图去获取该锁时合法的。然而,临界区的作用范围仅限于本进程中,其他的进程无法获取该锁(类似于静态全局变量对全局变量)。除此之外,临界区具有和互斥量相同的性质。

读写锁(Read-Write Lock)

致力于一种更加特定的场合的同步。对于一段数据,多个线程同时读取总是没问题的,但假设操作都不是原子型,只要有任何一个线程试图对这个数据进行修改,就必须使用同步手段来避免出错。如果我们使用上述信号量、互斥量或临界区中的任何一种来进行同步,尽管可以保证程序争取,但对于读取频繁,而仅仅偶尔写入的情况,会显得非常低效。读写锁可以避免这个问题。对于同一个锁,读写锁由两种获取方式,共享的(Shared)独占的(Exclusive)。当锁处于自由的状态时,试图以任何一种方式获取锁都能成功,并将锁置于对应的状态。如果锁处于共享状态,其他线程以共享的方式获取锁仍然会成功,此时这个锁分配给了多个线程。然而,如果其他线程试图以独占的方式获取已经处于共享状态的锁,那么它必须等待锁被所有的线程释放。相应地,处于独占状态的锁将阻止任何其他线程获取该锁,不论它们试图以哪种方式获取。读写锁的行为可以总结为如下表:

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

条件变量(Condition Variable)

作为一种同步手段,作用类似于一个栅栏。对于条件变量,线程可以有两种操作,首先线程可以等待条件变量,一个条件变量可以被多个线程等待。其次,线程可以唤醒条件变量,此时某个或所有等待此条件变量的线程都会被唤醒并继续支持。也就是说,使用条件变量可以让许多线程一起等待某个事件的发生,当事件发生时(条件变量被唤醒),所有的线程可以一起恢复执行。

可重入(Reentrant)与线程安全

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

有两种情况下函数会被重入

  • 多个线程同时执行这个函数。
  • 函数本身调用自身。

可重入是并发安全的强力保障,一个可重入的函数可以在多线程环境下放心使用。

过度优化

就算合理的使用了锁,也不一定能保证线程安全,这是源于落后的编译器技术已经无法满足日益增长的并发需求。很多看似无错的代码在优化和并发面前产生了麻烦。

早在几十年前,CPU就发展出了动态调度,在执行程序的时候为了提高效率有可能交换指令的顺序。同样,编译器在进行优化的时候,也可能为了效率而交换毫不相干的两条相邻指令。

我们可以使用volatie关键字试图阻止过度优化,volatie基本上可以做到两件事情:

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

但是volatie并不能阻止CPU的动态调换顺序。

1.6.3 多线程内部情况

三种线程模型

线程的并发执行是由多处理器或操作系统调度来实现的。但实际情况要更复杂一点;

大多数操作系统,包括Windos和Linux,都在内核里提供线程的支持,内核线程(这里的内核线程和Linux里面的kernel_thread不是一回事)和我们之前讨论的一样,由多处理器和调度来实现并发。然而用户实际使用的线程并不是内核线程,而是存在于用户态的用户线程。用户线程并不一定在操作系统内核里面有对应的内核线程,

1,一对一模型

对于直接支持线程的系统,一对一模型始终是最为简单的模型。对一对一模型来说,一个用户态的线程就唯一对应一个内核使用的线程(但反过来不一定)。

这样用户线程就具有了和内核线程一致的优点,线程之间的并发是真正的并发,一个线程因为某原因阻塞时,其他线程执行不会受影响,此外,一对一线程也可以让多线程程序在多处理器的系统上有更好的表现。

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

多对一模型一大问题是,如果其中一个用户线程阻塞,那么所有的线程将都无法执行,因为此时内核里的线程也会随之阻塞。另外,在多处理器系统上,处理器的增多线程性能也不会有明显的帮助。但同时,多对一模型得到的好处是高效的上下文切换和几乎无限制的线程数量。
3,多对多模型
结合了多对一模型和一对一模型的特点,将多个用户线程映射到少数但不止一个内核线程上。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值