线程的工作机制

128 阅读10分钟

什么是线程

线程(Thread),有时也叫做轻量级进程(Lightweight Process)

是程序执行流的最小单元

一个标准线程由线程ID、当前指令指针、寄存器集合和堆栈构成

通常说,一个进程会包含一个或多个线程,各线程之间共享进程的内存空间(包括代码段、数据段、堆等)及一些进程级资源(打开文件)

多线程

大多数的应用软件都不止使用一个线程,线程之间互不干扰并发执行,共享进程的全局变量和堆的数据

多线程的优势:

  • 某个操作可能会陷入长时间的等待,无法继续执行,多线程能够避免长时间的等待
  • 某个操作会消耗大量时间,多线程可以让一个线程负责交互,一盒线程负责计算,避免中断用户的使用
  • 满足程序逻辑上的并发操作需求
  • 更好的发挥现今多核处理器的计算能力
  • 线程之间的数据共享要比进程之间的数据共享高效

线程的访问权限

线程的访问很自由,可以访问进程内存中的所有数据,甚至包括其他线程的堆栈(知道堆栈地址的情况下,少见情况),线程也拥有自己的存储空间

  • 栈。并不是无法被其他线程访问,一般情况下可以认为是私有数据
  • 线程局部存储(Thread Local Storage)。是某些操作系统为线程单独提供的私有空间,容量有限
  • 寄存器,包括pc寄存器,寄存器是执行流的基本数据,为线程私有
线程私有线程之间共享
局部变量全局变量
函数参数堆上的数据
tls数据函数里的静态变量
程序代码
打开的文件

寄存器是寄存器是CPU内部用来存放数据的一些小型存储区域,用来暂时存放参与运算的数据和运算结果

pc是指程序计数器,是用于存放下一条指令所在单元的地址的地方

线程调度和优先级

线程数量小于处理器数量时,此时的线程并发是真正的并发

反之,会出现一个处理器运行多个线程的情况,此时,并发是一种模拟出来的状态。

操作系统会让多个线程轮流执行,每次一小段时间,这样看起来依然是同时执行

在一个处理器上切换不同线程的行为称为线程调度(Thread Schedule)

线程在调度中拥有三个状态

  • 运行
  • 就绪。可以立即运行,但是cpu被占用
  • 等待。正在等待某一事件发生,无法执行,比如I/O或者同步任务

运行中的线程拥有一段可执行时间,称为时间片。时间片用尽,进入就绪状态,如果时间片未用尽,线程开始进入等待状态,调度系统会转而选择另一个就绪状态的线程执行

这便是转轮法

主流的调度方式都带有优先级调度转轮法的痕迹

优先级调度的系统中,具有高优先级的线程会更早地执行,而低优先级的线程常常等到系统中没有高优先级的可执行线程存在时才执行

windows和linux支持用户手动设置线程优先级,系统还会根据线程的表现自动调整优先级,以使得调度更加高效。一般,频繁进入等待状态的线程要比要比频繁进行大量计算,每次都把时间片用尽的线程受欢迎的多。因为频繁等待的线程通常只占用很少的时间,即I/O密集型线程,很少等待的线程称为CPU密集型线程

I/O 线程主要用于与外部系统交互信息,如输入输出,CPU仅需在任务开始的时候,将任务的参数传递给设备,然后启动硬件设备即可。 等任务完成的时候,CPU收到一个通知,一般来说是一个硬件的中断信号,此时CPU继续后继的处理工作。 在处理过程中,CPU是不必完全参与处理过程的,如果正在运行的线程不交出CPU的控制权,那么线程也只能处于等待状态,即使操作系统将当前的CPU调度给其他线程,此时线程所占用的空间还是被占用,而并没有CPU处理这个线程,可能出现线程资源浪费的问题。 如果这是一个网络服务程序,每一个网络连接都使用一个线程管理,可能出现大量线程都在等待网络通信,随着网络连接的不断增加,处于等待状态的线程将会很消耗尽所有的内存资源。可以考虑使用线程池解决这个问题

优先级调度的系统,存在饿死的现象,是因为他的优先级较低,总有比他优先级高的线程需要执行,操作系统会逐步提升那些等待时间过长的线程的优先级,这样一来,一个线程只要等待了足够久的时间,他的优先级一定会提高到足够让他执行的程度

对于一个线程来说,提升优先级的方式有三种

  • 用户指定优先级
  • 根据进入等待时间的频繁程度提升或者降低优先级
  • 长时间得不到执行被提升优先级

可抢占线程和不可抢占线程

线程用完时间片进入就绪状态的过程叫做抢占,即之后的线程抢占了当前线程。

在早期的一些系统里,线程是不可抢占的,它必须手动发出一个放弃执行的命令

在这种调度模型下,线程必须主动进入就绪状态,而不是靠时间片用尽来被强制进入

在两种情况下,线程主动放弃执行

  1. 试图等待某个事件(I/O)
  2. 线程主动放弃时间片

不可抢占线程执行的时候有个特点是线程的调度时机是确定的,可以避免抢占式线程里调度时机不确定产生的问题。但是非抢占式线程已经非常少见

linux的多线程

windows内核有明确的线程和进程概念,并且有明确的API去操作,但是linux中,线程不是通用的概念

linux将所有的执行实体称为任务(task),每一个任务都类似一个单线程的进程,具有内存空间、执行实体、文件资源等,不过linux下不同的任务之间可以选择共享内存,共享了同一个内存空间的多个任务构成了一个进程,这些任务也就成了这个进程里的线程

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

fork产生新任务的速度非常快,因为部复制原任务的内存空间,而是和原任务一起共享一个写时复制的内存空间,两个任务可以同时自由地读取内存,但任意一个任务试图对内存进行修改时,内存就会复制一份提供给修改方单独使用

fork只能产生本任务的镜像。exec可以用新的可执行镜像替换当前的可执行映像,在fork了产生一个新任务后,新任务可以调用exec来执行新的可执行文件

要产生新线程,则使用clone,会在指定的位置开始执行,并且共享当前进程的内存空间和文件等

线程安全

线程可以随时修改全局变量和堆数据,因此多线程程序在并发时数据的一致性变得非常重要

一个共享数据可能同时被两个线程修改

单指令的操作称为原子的(Atomic),因为单条指令是不会被打断的,很多体系结构都提供了一些常用操作的原子指令,使用这些指令函数时,操作系统能保证是原子操作,但是这些指令仅适用于比较简单特定的场合复杂场合下,需要

同步与锁

未了避免多个线程同时读写同一个数据产生问题,我们要将各个线程对同一个数据的访问同步(Synchronization)

同步是指当一个线程访问数据未结束的时候,其他线程不得对同一个数据进行访问,这样一来,对数据的访问就原子化了

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

二元信号量(Binary Semaphore)

是最简单的一种锁,仅有两种状态:占用与非占用。占用时后面的线程需要等待

信号量

有些资源允许多个线程并发访问,是多元信号,简称信号量 ,线程访问的时候会有如下步骤

  1. 将信号量的值减1
  2. 如果信号量小于0,进入等待,否则继续执行

访问完后,释放信号量

  1. 将信号量的值增加1
  2. 如果信号量的值小于1,唤醒一个等待中的线程

互斥量

与二元信号量相似,同时仅允许一个线程访问。解铃还须系铃人,互斥量只能由获取它的线程释放,而信号量可以由不同的线程获取,由不同的线程释放

临界区

比互斥量更严格的同步手段。获取临界区的锁成为称为进入临界区,释放锁称为离开临界区

临界区的锁,只能由本进程获取,其他方式的锁,可以被其他进程获取

读写锁

特定的同步场景。有两种获取方式,共享的和独占的。

锁处于自由状态时,任何方式获取锁都能成功,并可以将锁置于对应状态

如过处于共享状态,其他线程也可以获取,此时这个锁分配给多个线程。此时,如果其他线程试图以独占的方式获取已经处于共享状态的锁,必须等锁被所有线程释放

处于独占状态时,其他线程无法获取

条件变量

线程可以等待条件变量,一个条件变量可被多个线程等待,使用条件变量可以让许多线程一起等待某个事件的发生,当事件发生时,所有线程一起恢复执行

可重入与线程安全

一个函数被重入,表示没有执行完成

两种情况导致重入

  1. 多个线程同时执行这个函数
  2. 函数自身调用自身

线程安全的可重入函数需要满足以下条件

  1. 不使用任何静态或者全局的非const变量
  2. 不返回任何静态或全局的非const 变量的指针
  3. 仅依赖于调用方提供的参数
  4. 不依赖任何单个资源的锁
  5. 不调用任何不可重入的函数

过度优化

编译器可能会存在过度优化:

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