Linux 基础 - 11. 线程

Linux 基础 - 11. 线程

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SOupoXp3-1589783553270)(https://linotes.imliloli.com/assets/images/bio-photo.jpg)]

Hawk Zhang

\11. 线程11.1 线程11.1.1 线程的概念11.1.2 LINUX 的线程实现11.1.3 线程的特点11.1.4 用户线程与内核线程11.2 进程与线程的区别11.2.1 进程与线程的相同点11.2.2 实现方式的差异11.2.3 多任务程序设计模式的区别11.2.4 实体间通信方式的不同11.2.5 控制方式的区别11.2.6 资源管理方式的区别11.2.7 进程池与线程池的技术实现差别11.3 多任务11.3.1 多任务操作系统11.3.2 多线程11.3.3 多进程11.3.4 操作系统分类

11.1 线程

11.1.1 线程的概念

Thread

随着技术发展,在执行一些细小任务时,本身无需分配单独资源时(多个任务共享同一组资源即可,比如所有子进程共享父进程的资源),进程的实现机制依然会繁琐的将资源分割,这样造成浪费,而且还消耗时间。于是就有了专门的多任务技术被创造出来 —— 线程。

进程是一个程序运行的时候被 CPU 抽象出来的,一个程序运行后被抽象为一个进程。但是线程是从一个进程里面分割出来的,由于 CPU 处理进程的时候是采用 时间片轮转 的方式,所以要把一个大个进程给分割成多个线程。

  • 实体:线程是进程的一个实体,是 CPU 调度和分派的基本单位,它是比进程更小的、能独立运行的基本单位,是 真正的执行实体
  • 与进程关系:为了让进程完成一定的工作,进程必须至少包含一个线程。一条线程指的是进程中一个 单一顺序的控制流,线程是属于进程的,线程运行在进程空间内。当进程退出时,该进程所产生的线程都会被强制退出并清除。
  • 资源:线程在不需要独立资源的情况下就可以运行。如此一来会极大节省资源开销,以及处理时间。线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。对于一些 “要求同时进行,并且又要共享某些变量的并发操作”,只能用线程,不能用进程。线程有 自己的堆栈和局部变量,但线程之间没有单独的地址空间,一个线程死掉就等于整个进程死掉。
  • 线程的操作:创建、终止、同步(join、block)、调度、数据管理、进程交互。线程若要 主动终止,需要调用 pthread_exit() 函数 ,主线程需要调用 pthread_join() 来回收,前提是该线程没有被 detached
  • 父子线程:父线程不会对其创建的子线程进行维护,子线程也不知道自己的爹是谁。一个线程可以创建和撤销另一个线程
  • 多线程:同一进程中的多条线程将 共享 该进程的 相同地址空间,可以与同进程中的其他线程 共享数据,但拥有 自己的栈空间,拥有 独立的执行序列。一个进程中可以 并发多个线程,每条线程并行执行不同的任务。

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-w1rlxzB2-1589783553273)(https://linotes.imliloli.com/assets/images/thread.png)]

引入线程带来的主要好处

  • 在进程内 创建、终止线程 比创建、终止进程要
  • 同一进程内的 线程间切换 比进程间的切换要 ,尤其是用户线程间的切换。

从堆栈的角度理解线程

线程本质就是 堆栈,当一段程序在执行,能代表它的是它的过去和现在。

“过去” 在 堆栈 中,”现在” 则是 CPU 的 所有寄存器。如果我们要挂起一个线程,我们把寄存器也保存到堆栈中,我们就具有它的所有状态,可以随时恢复它。

当我们 切换线程 的时候,同时切换它的 地址空间(通过修改 MMU 即可),则我们认为发生了进程切换。

所以进程的本质是地址空间,我们可以认为地址空间决定了进程是否发生切换。

线程的状态

线程有四种基本状态,分别为:

  • 产生 spawn
  • 阻断 block
  • 非阻断 unblock
  • 结束 finish

线程上下文

类似于进程上下文,线程也有上下文。当线程被抢占时,就会发生线程之间的上下文切换。

如果线程属于相同的进程,它们共享相同的地址空间,进程需要恢复的多数信息对于线程而言是不需要的。尽管进程和它的线程共享了很多内容,但最为重要的是其地址空间和资源,有些信息对于线程而言是本地且唯一的,而线程的其他方面包含在进程的各个段的内部。

上下文内容 进程 线程
指向可执行文件的指针 x
x x
内存(数据段和堆) x
状态 x x
优先级 x x
程序 I/O 的状态 x
授予权限 x
调度信息 x
审计信息 x
有关资源的信息(文件描述符,读/写指针) x
有关事件和信号的信息 x
寄存器组(栈指针,指令计数器等) x x

线程本地(且唯一)的信息包括线程id、处理器寄存器(当线程执行时寄存器的状态,包括程序计数器和栈指针)、线程状态及优先级、线程特定数 据(thread-specific data,TSD)。

线程id是在创建线程时指定的。线程能够访问它所属进程的数据段,因此线程可以读写它所属进程的全局声明数据。进程中一个线程做出的 任何改动都可以被进程中的所有线程以及主线程获得。在多数情况下,这要求某种类型的同步以防止无意的更新。线程的局部声明变量不应当被任何对等线程访问。 它们被放置到线程栈中,而且当线程完成时,它们便会被移走。

11.1.2 Linux 的线程实现

当 Linux 最初开发时,在内核中并不能真正支持线程。但是它的确可以通过 clone() 系统调用将进程作为可调度的实体。这个调用创建了调用进程(calling process)的一个拷贝,该拷贝与调用进程共享相同的地址空间。LinuxThreads 项目使用这个调用来完全在用户空间模拟对线程的支持。不幸的是,这种方法有一些缺点,尤其是在信号处理、调度和进程间同步方面都存在问题。另外,这个线程模型也不符合 POSIX 的要求。

为了完善 Linux 的线程实现,Red Hat 的一些开发人员开展了 NPTL (Native POSIX Thread Library)项目。它是 Linux 线程的一个新实现,它克服了 LinuxThreads 的缺点,同时也符合 POSIX 的需求。与 LinuxThreads 相比,它在性能和稳定性方面都提供了重大的改进。与 LinuxThreads 一样,NPTL 也实现了一对一的模型。

实际使用中,创建线程时并不采用 clone 系统调用,而是采用 线程库函数。常用线程库有 Linux-Native 线程库和 POSIX 线程库(NPTL)。其中应用最为广泛的是 POSIX 线程库。因此经常在多线程程序中看到的是 pthread_create 而非 clone

我们知道,库是建立在操作系统层面上的功能集合,因而它的功能都是操作系统提供的。由此可知,线程库的内部很可能实现了 clone 的调用。不管是进程还是线程的实体,都是操作系统上运行的实体。

11.1.3 线程的特点

同一进程中的线程共享:

  • 进程指令
  • 大部分数据
  • 打开的文件(文件描述符)
  • 信号及信号处理器
  • 当前工作路径
  • UID、GID

每个线程有独立的:

  • 线程 ID
  • 寄存器环境
  • 线程本地存储(Thread-local Storage)
  • 调用栈(Call Stack)
  • 优先级
  • 返回值

11.1.4 用户线程与内核线程

用户线程:UserLevel Threads,ULT

内核线程:Kernel Supported threads,KST

linux 内核不存在整真正意义上的线程。linux 将所有的执行实体都称之为任务(task),每一个任务在概念上都类似于一个单线程的进程,具有内存空间、执行实体、文件资源等。但是不同任务之间可以选择共用内存空间,因而在实际意义上,共享同一个内存空间的多个任务构成了一个进程,而这些任务就成为这个任务里面的线程。

内核线程

对于 一切的进程,无论是系统进程还是用户进程,进程的 创建和撤销,以及 I/O 操作 都是利用系统调用进入到内核,由内核处理完成,所以说在 KST 下,所有进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。

内核空间实现还为每个内核线程设置了一个 线程控制块(Thread Control Block,TCB),内核是根据该控制块而感知某个线程是否存在,并加以控制的。在一定程度上类似于进程,只是 创建、调度的开销要比进程小。有的统计是 1:10。

内核线程可以在全系统内进行资源的竞争。

内核线程 切换由内核控制,当线程进行切换的时候,由用户态转化为内核态。切换完毕要从内核态返回用户态,即 存在用户态和内核态之间的转换

优点
  • 在多处理器系统中,内核能够同时调度同一进程中 多个线程并行执行到多个处理器中
  • 如果进程中的 一个线程被阻塞内核可以调度 同一个进程中的 另一个线程
缺点

线程切换的代价太大,在同一个进程中,从一个线程切换到另一个线程时,需要从用户态,进入到内核态并且由内核切换。因为 线程调度和管理在内核实现

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lFFUWLTp-1589783553275)(https://linotes.imliloli.com/assets/images/kst.jpg)]

内核线程驻留在 内核空间,它们是内核对象。

有了内核线程,每个用户线程被映射或绑定到一个内核线程。用户线程在其生命期内都会绑定到该内核线程。一旦用户线程终止,两个线程都将离开系统。这被称作 “一对一” 线程映射。

内核中的操作系统调度器负责管理、调度并分派这些线程。运行时库为每个用户线程请求一个内核线程。

操作系统的内存管理和调度子系统必须要考虑到数量巨大的用户线程。您必须了解每个进程允许的线程的最大数目是多少。

操作系统 为每个线程创建上下文

进程的 每个线程 在资源可用时 都可以被指派到处理器内核

用户线程

用户进程 ULT 仅存在于 用户空间 中。

用户线程的 创建、撤销、线程之间的同步和通信 等功能,都 无需系统调用 来实现。

同一进程的线程之间切换不需要内核支持,内核也完全不会知道用户线程的存在

但是有一点必须注意:设置了用户线程的系统,其调度仍然是以进程为单位进行的哦。

优点:
  1. 线程切换不需要转换到内核空间,故切换开销小,速度非常快
  2. 调度算法可以是进程专用,由用户程序进行指定
  3. 用户线程实现和操作系统无关
缺点:
  1. 系统调用的阻塞问题:对应用程序来讲,同一进程中只能同时有一个线程在运行,一个线程的阻塞将导致整个进程中所有线程的阻塞
  2. 由于这里的处理器时间片分配是以进程为基本单位,所以 每个线程执行的时间相对减少

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-y6Sa2YXM-1589783553278)(https://linotes.imliloli.com/assets/images/ult.jpg)]

运行时库管理这些线程,它也位于用户空间。它们对于操作系统是不可见的,因此无法被调度到处理器内核。

每个线程并 不具有自身的线程上下文。因此,就线程的同时执行而言,任意给定时刻,每个进程只能够有一个线程在运行,而且 只有一个处理器内核会被分配给该进程。对于一个进程,可能有成千上万个用户线程,但是它们对系统资源没有影响。运行时库调度并分派这些线程。如同在图中看到的那样,库调度器从进程的多个线程中 选择一个线程,然后该线程和该进程允许的一个内核线程关联起来。内核线程将被操作系统调度器指派到处理器内核。用户线程是一种 “多对一” 的线程映射。

混合方式

在很多的操作系统中把 ULT 和 KLT 进行组合,集中了它们的优点。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-B3zfQTLj-1589783553280)(https://linotes.imliloli.com/assets/images/ult.kst.jpg)]

混合线程 实现是用户线程和内核线程的交叉,使得 库和操作系统都可以管理线程。用户线程由运行时库调度器管理,内核线程由操作系统调度器管理。

在这种实现中,进程有着自己的内核线程池。可运行的用户线程由运行时库分派,并标记为就绪态的可用线程。操作系统选择用户线程并将它映射到线程池中的可用内核线程。多个用户线程可以分配给相同的内核线程

图中,进程 A 在它的线程池中有两个内核线程,而进程 B 有 3 个内核线程。

进程 A 的用户线程 2 和 3 被映射到内核线程 2。

进程 B 有 5 个线程,用户线程 1 和 2 映射到同一个内核线程 3,用户线程 4 和 5 映射到内核同一个内核线程 5。

当创建新的用户线程时,只需要简单地将它映射到线程池中现有的一个内核线程即可。

这种实现使用了 “多对多“” 线程映射。该方法中尽量使用多对一映射。很多用户线程将会映射到一个内核线程。因此,对内核线程的请求将会少于用户线程的数目。

内核线程池 不会被销毁和重建,这些线程 总是存在于系统中。它们会在必要时分配给不同的用户线程,而不是当创建新的用户线程时就创建一个新的内核线程,而纯内核线程被创建时,就会创建一个新的内核线程。只对池中的每个线程创建上下文。有了内核线程和混合线程,操作系统分配一组处理器内核,进程的线程可以在这些处理器内核之上运行。线程只能在为它们所属线程指派的处理器内核上运行。

11.2 进程与线程的区别

  • 一个程序至少有一个进程,一个进程至少有一个线程。
  • 线程的划分尺度小于进程,使得多线程程序的并发性高。
  • 线程执行开销小,但不利于资源的管理和保护;而进程正相反。
  • 进程在执行过程中拥有 独立的内存单元,而多个线程 共享内存,从而极大地提高了程序的运行效率。
  • 每个独立的线程有一个程序运行的入口、顺序执行序列和程序的出口。但是线程不能够独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制。
  • 从逻辑角度来看,多线程的意义在于一个应用程序中,有 多个执行部分可以同时执行。但操作系统并没有将多个线程看做多个独立的应用,来实现进程的调度和管理以及资源分配。
  • 线程适合于在 SMP(对称多处理) 机器上运行,而进程则可以跨机器迁移。
对比维度 多进程 多线程 总结
数据共享、同步 数据共享复杂,需要进程间通信;数据是分开的,同步简单 因为共享进程数据,数据共享简单,但也是因为这个原因,导致同步复杂 各有优势
内存、CPU 占用内存多,切换复杂,CPU 利用率低 占用内存少,切换简单,CPU 利用率高 线程占优
创建、销毁、切换 创建、销毁、切换复杂,速度慢 创建、销毁、切换简单,速度很快 线程占优
编程、调试 编程简单,调试简单 编程复杂,调试复杂 进程占优
可靠性 进程间不会互相影响 一个线程挂掉将导致整个进程挂掉 进程占优
分布式 适应于多核、多机分布式;如果一台机器不够,扩展到多台机器比较简单 适应于多核分布式 进程占优

11.2.1 进程与线程的相同点

  • 都是用来实现多任务并发的技术手段
  • 都可以独立调度
  • 都具有各自的实体,是系统独立管理的对象个体
  • 在系统层面,都可以通过技术手段实现二者的控制
  • 进程与线程的状态非常相似
  • 在多任务程序中,子进程(子线程)的调度一般与父进程(父线程)平等竞争

在 Linux 内核 2.4 版本之前,线程的实现和管理方式就是完全按照进程方式实现的。在 2.6 版内核以后才有了单独的线程实现。

11.2.2 实现方式的差异

进程是 资源分配 的基本单位,线程是 调度 的基本单位

  • 进程和线程都可以被调度,但 线程是更小的可以调度的单位,即只要达到线程的水平就可以被调度了。
  • 分配资源时的对象必须是进程,不会给一个线程单独分配系统管理的资源。若要运行一个任务,想要获得资源,最起码得有进程,其他子任务可以以线程的身份运行,资源共享就可以了。
  • 进程的个体间是完全独立的,而线程间是彼此依存的。多进程环境中,任何一个进程的终止,不会影响到其他进程。而多线程环境中,父线程终止,全部子线程被迫终止,因为没有了资源。而任何一个子线程的终止,一般不会影响其他线程,除非子线程执行了 exit() 系统调用。任何一个子线程执行 exit() ,全部线程同时灭亡。
  • 多线程程序中至少有一个主线程,这个主线程其实就是有 main() 函数 的进程。它是整个程序的进程,所有线程都是它的子线程。我们通常把具有多线程的主进程称之为 主线程
  • 进程 的实现是调用 fork 系统调用,线程 的实现是调用 clone 系统调用。fork 是将父进程的 全部资源 复制给了子进程,而 clone 只是 复制了一小部分必要的资源,可以说 fork 实现的是 clone 的加强完整版。后来操作系统还进一步优化 frok 实现 — 写时复制技术,在子进程需要复制资源(如子进程执行写入动作,更改父进程内存空间)时才复制,否则创建子进程时先不复制。

main() 函数 】
在 C 语言当中,一个程序,无论复杂或简单,总体上都是一个 “函数”;这个函数就称为 main() 函数,也就是 “主函数”。比如有个 “做菜” 程序,那么 “做菜” 这个过程就是 “主函数”。在主函数中,根据情况,你可能还需要调用 “买菜,切菜,炒菜” 等子函数。
main() 函数在程序中大多数是必须存在的,但是依然有例外情况,比如 windows 编程中可以编写一个动态链接库(dll)模块,这是其他 windows 程序可以使用的代码。由于 DLL 模块不是独立的程序,因此不需要 main 函数。再比如,用于专业环境的程序 — 如机器人中的控制芯片 — 可能不需要 main() 函数。
C 程序最大的特点就是所有的程序都是用函数来装配的。main() 称之为主函数,是 所有程序运行的入口。其余函数分为 有参无参 两种,均由 main() 函数或其它一般函数调用,若调用的是有参函数,则参数在调用时传递。

【 写时复制 】
写入时复制(Copy-on-write,COW)是一种计算机程序设计领域的 优化策略
其核心思想是,如果有多个调用者(callers)同时请求相同的资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针,指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。
这过程对其他的调用者都是透明的(transparently)。此作法主要的优点是如果调用者没有修改该资源,就不会有副本(private copy)被创建,因此多个调用者只是读取操作时可以共享同一份资源。

11.2.3 多任务程序设计模式的区别

  • 由于进程间相互独立,适合设计需要资源独立管理的多进程程序。但如果进程之间需要通信的话,就得采用进程间的通信方式,通常是耗时间的。
  • 同一进程的线程之间不需要通信,就可以共享资源。但父线程无法通过复用变量的方式来多次执行函数,无法在不同线程分别执行,要处理这样的任务就需要进行线程间通信,父线程显得效率有所下降。多个子线程在同时执行写入操作时需要实现互斥,否则数据就写 “脏” 了。

11.2.4 实体间通信方式的不同

进程间通信方式
  • 共享内存
  • 消息队列
  • 信号量
  • 有名管道
  • 无名管道
  • 信号
  • 文件
  • socket
线程间通信方式

以上进程间通信方式都可沿用,而且还有自己独特的几种方式:

  • 互斥量
  • 自旋锁
  • 条件变量
  • 读写锁
  • 线程信号
  • 全局变量

线程间通信用的信号不能采用进程间的信号,因为信号是基于进程为单位的,而线程是共属于同一进程空间的,因此要采用线程信号。

进程间采用的通信方式,要么需要 切换内核上下文,要么需要访问外设(有名管道、文件)。所以速度会比较慢。

而线程采用自己特有的通信方式的话,基本都在自己的进程空间内完成,不存在切换,所以通信速度会较快。

进程与线程间通信

除信号以外的其他进程间通信方式都可采用。

11.2.5 控制方式的区别

进程的 ID 为 pid_t 类型,实际为一个 int 型的变量,也就是说是有限的。

在全系统中,PID 是唯一标识,对于进程的管理都是通过 PID 来实现的。每创建一个进程,内核中就会创建一个进程描述符来存储该进程的全部信息。

每一个存储进程信息的节点也都保存着自己的 PID。需要管理该进程时,就通过这个 ID 来实现(比如发送信号)。当子进程结束,要回收时(子进程调用 exit() 退出,或代码执行完),需要通过 wait() 系统调用来进行,未回收的消亡进程会成为僵尸进程,其进程实体已经不复存在,但会虚占 PID 资源,因此回收是有必要的。

线程的 ID 是一个 long 型变量,它的范围大的多,管理方式也不一样。

线程 ID 一般在本进程空间内作用就可以了,当然系统在管理线程时也需要记录其信息。方式是,在内核创建一个内核态线程与之对应,也就是说 每一个用户创建的线程都有一个内核态线程与之对应。但这种对应关系不是一对一,而是 多对一 的关系,即 一个内核态线程可以对应多个用户线程

11.2.6 资源管理方式的区别

进程的资源是相互独立的,如果多进程间需要共享资源,就要用到进程间的通信方式了,比如 共享内存。它是脱离于进程本身存在的,是全系统都可见的,进程的单点故障并不会损毁数据。共享内存是全系统可见的,如果编程不当,进程资源有可能被他人误读误写。

线程间要使用共享资源不需要用共享内存,直接使用 全局变量 即可,或者 malloc() 动态申请内存,更加方便直接。

实际使用中,为了使程序内资源充分规整,都 采用共享内存来存储核心数据。不管进程还是线程,都采用这种方式。原因之一就是,共享内存是脱离进程的资源,如果进程发生意外终止的话,共享内存可以独立存在不会被回收。

11.2.7 进程池与线程池的技术实现差别

:进程和线程的创建是需要一定的时间的,并且系统所能承受的进程和线程数也是有上限的,如果在程序启动时,就预先创建一些子进程或线程,在需要时就可以直接使用。

进程池

分开保存 PID,用数组或链表。做一个足够大的池,便于快速响应。

任务不多时,让闲置进程通过 pause() 挂起,也可用信号量挂起,还可以用 IPC(进程间通信)阻塞等多种方法。

有任务要执行时就唤醒进程,让它从预先指定的地方去读取任务,如可以用函数指针,在约定的地方设置代码段指针。再通过共享内存把要处理的数据设置好,子进程就知道怎么做了。执行完之后再来一次进程间通信,然后自己继续冬眠,父进程就知道孩子干完了,收割成果。

最后结束时,回收子进程,向各进程发送信号唤醒,改变激活状态让其主动结束,然后逐个 wait() 就可以了。

线程池

线程池的思想与上述类似,只是更为轻量级,所以调度起来不用等待额外的资源。

要让线程阻塞,用条件变量就是了,需要干活的时候父线程改变条件,子线程就被激活。

线程间通信方式就不用赘述了,不用繁琐的通信就能达成,比起进程间效率要高一些。

线程干完之后自己再改变条件,这样父线程也就知道该收割成果了。

整个程序结束时,逐个改变条件并改变激活状态让子线程结束,最后逐个回收即可。

11.3 多任务

Linux 是多任务操作系统,多个进程可以同时运行,通过 抢占时间片 的方式来控制。在一定时间(数毫秒)以后,操作系统把操作从一个进程转移到另一个进程。

11.3.1 多任务操作系统

操作系统将 CPU 的 时间片 分配给 多个线程,每个线程在操作系统指定的时间片内完成(注意,这里的多个线程是 分属于不同进程 的)。

操作系统不断的从一个线程的执行切换到另一个线程的执行,如此往复,宏观上看来,就 好像是多个线程在一起执行

由于这多个线程分属于不同的进程,因此在我们看来,就 好像是多个进程在同时执行,这样就实现了 多任务

11.3.2 多线程

在任何时间,每个 CPU 同时只运行一个进程

多线程技术

某个操作可能会陷入长时间等待,等待的线程会进入睡眠状态,无法继续执行。多线程执行可以 有效利用等待的时间。典型的例子是等待网络响应,这可能要花费数秒甚至数十秒。

某个操作(常常是计算)会消耗大量的时间,如果只有一个线程,程序和用户之间的交互会中断。多线程可以让一个线程负责交互,另一个线程负责计算。

程序逻辑本身就要求并发操作,例如一个多端下载软件(例如Bittorrent)。多 CPU 或多核计算机本身具备同时执行多个线程的能力,此时,单线程程序无法全面地发挥计算机的全部计算能力。相对于多进程应用,多线程在数据共享方面效率要高很多。在多核、多 CPU、或支持超线程(Hyper-threading)的 CPU 上,使用多线程程序设计,可提高程序的执行吞吐率。 在单 CPU、单核的计算机上,使用多线程技术,可以把进程中负责 “I/O 处理、人机交互等常被阻塞的部分”,与密集计算的部分分开来执行,编写专门的 workhorse 线程用于执行密集计算,从而提高了程序的执行效率。

Linux 的多线程

在 Linux 中,进行 CPU 分配是以线程为单位 的。

Windows 对进程和线程的实现如同教科书一般标准,Windows 内核有明确的线程和进程的概念。在 Windows API 中,可以使用明确的 API:CreateProcess 和 CreateThread 来创建进程和线程,并且有一系列的 API 来操纵它们。但对于 Linux 来说,线程并不是一个通用的概念。

Linux 对多线程的支持颇为贫乏,事实上,在 Linux 内核中并不存在真正意义上的线程概念。Linux 将所有的执行实体(无论是线程还是进程)都称为 任务(Task),每一个任务概念上都类似于一个单线程的进程,具有内存空间、执行实体、文件资源等。不过,Linux 下不同的任务之间可以选择共享内存空间,因而在实际意义上,共享了同一个内存空间的多个任务构成了一个进程,这些任务也就成了这个进程里的线程。

线程数小于 CPU 数量

并行 运行 】

如果一台计算机有多个 CPU,如果进程数小于 CPU 数,则不同的线程要以分配给不同的 CPU 来运行,多个线程真正的同时运行,这便是 并行运行

并行运行的效率显然高于并发运行,所以在多 CPU 的计算机中,多任务的效率比较高。但是,如果在多 CPU 计算机中只运行一个进程(线程),就不能发挥多 CPU 的优势。

线程数量大于 CPU 数量

至少有一个处理器会运行多个线程。这是大部分用户使用计算机的常态。

并发 运行 】

通常 CPU 的数量要小于进程的数量,要让它一心多用,同时运行多个线程,必须使用 并发技术

于是在 CPU 空闲下来变得可用之前,其余的线程必须等待,直到它们可以被运行。

线程调度

实现并发技术相当复杂,最容易理解的是时间片轮转线程调度算法,即轮转法(Round Robin):

在操作系统的管理下,所有正在运行的线程轮流使用 CPU,每个进程允许占用 CPU 的时间非常短,通常是几十到几百毫秒,这样用户根本感觉不出来 CPU 是在轮流为多个线程服务,就好象所有的线程都在不间断地运行一样。但实际上在任何一个时间内有且仅有一个线程占有 CPU。这样的一个不断在处理器上切换不同的线程的行为称之为 线程调度(Thread Schedule),这决定了线程之间 交错执行 的特点。。

处于运行中线程拥有一段可以执行的时间,这段时间称为 时间片(Time Slice),当时间片用尽的时候,该进程将进入就绪状态。如果在时间片用尽之前进程就开始等待某事件,那么它将进入等待状态。每当一个线程离开运行状态时,调度系统就会选择一个其他的就绪线程继续执行。

优先级调度

线程调度自多任务操作系统问世以来就不断地被提出不同的方案和算法,还有一种 优先级调度(Priority Schedule) 的算法。优先级调度则决定了线程按照什么 顺序 轮流执行。

在具有优先级调度的系统中,线程都拥有各自的 线程优先级(Thread Priority)。具有高优先级的线程会更早地执行,而低优先级的线程常常要等待到系统中已经没有高优先级的可执行的线程存在时才能够执行。

线程的优先级不仅可以由用户 手动设置,系统还会根据不同线程的表现 自动调整 优先级,以使得调度更有效率。

例如通常情况下,频繁地进入等待状态(进入等待状态,会放弃之后仍然可占用的时间份额)的线程(例如处理 I/O 的线程)比频繁进行大量计算、以至于每次都要把时间片全部用尽的线程要受欢迎得多。其实道理很简单,频繁等待的线程通常只占用很少的时间,CPU 也喜欢 先捏软柿子。我们一般把 频繁等待的线程称之为 I/O 密集型线程(IO Bound Thread),而把 很少等待的线程称为 CPU 密集型线程(CPU Bound Thread)。

I/O 密集型线程总是比 CPU 密集型线程容易得到优先级的提升。

在优先级调度下,存在一种 饿死(Starvation)的现象,一个线程被饿死,是说它的优先级较低,在它执行之前,总是有较高优先级的线程试图执行,因此这个低优先级线程始终无法执行。当一个 CPU 密集型的线程获得较高的优先级时,许多低优先级的进程就很可能饿死。而一个高优先级的IO密集型线程由于大部分时间都处于等待状态,因此相对不容易造成其他线程饿死。为了避免饿死现象,调度系统常常会逐步提升那些等待了过长时间的得不到执行的线程的优先级。在这样的手段下,一个线程只要等待足够长的时间,其优先级一定会提高到足够让它执行的程度。

因此,线程的优先级改变一般有三种方式:

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

上面讨论的线程调度有一个特点,那就是线程在用尽时间片之后会被强制剥夺继续执行的权利,而进入就绪状态,这个过程叫做 抢占(Preemption),即之后执行的别的线程抢占了当前线程。

在早期的一些系统(例如 Windows 3.1)里,线程是不可抢占的。线程必须手动发出一个放弃执行的命令,才能让其他的线程得到执行。在这样的调度模型下,线程必须主动进入就绪状态,而不是靠时间片用尽来被强制进入。如果线程始终拒绝进入就绪状态,并且也不进行任何的等待操作,那么其他的线程将永远无法执行。

在不可抢占线程中,线程主动放弃执行无非两种情况:

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

因此,在不可抢占线程执行的时候,有一个显著的特点,那就是线程调度的时机是确定的,线程调度只会发生在线程主动放弃执行或线程等待某事件的时候。这样可以避免一些因为抢占式线程里调度时机不确定而产生的问题。但即使如此,非抢占式线程在今日已经十分少见。

线程安全

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

竞争与原子操作

多个线程同时访问一个共享数据,可能造成很恶劣的后果。某些操作(如自增 ++)在多线程环境下会出现错误,因为这个操作被编译为汇编代码之后变成不止一条指令,因此在执行的时候可能执行了一半就被调度系统打断,去执行别的代码。我们把 单指令的操作 称为 原子的(Atomic),因为无论如何,单条指令的执行是 不会被打断 的。为了避免出错,很多体系结构都提供了一些常用操作的原子指令。

同步与锁

尽管原子操作指令非常方便,但是它们仅适用于比较简单特定的场合。在复杂的场合下,比如我们要保证一个复杂的数据结构更改的原子性,原子操作指令就力不从心了。这里我们需要更加通用的手段:锁。

为了避免多个线程同时读写同一个数据而产生不可预料的后果,我们需要将各个线程对同一个数据的访问同步(Synchronization)。

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

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

多线程内部情况

线程的并发执行是由多处理器或操作系统调度来实现的。但实际情况要更为复杂一些:大多数操作系统,包括 Windows 和 Linux,都在内核里提供线程的支持,内核线程由多处理器或调度来实现并发。然而用户实际使用的线程并不是内核线程,而是存在于用户态的用户线程。用户线程并不一定在操作系统内核里对应同等数量的内核线程,例如某些轻量级的线程库,对用户来说如果有三个线程在同时执行,对内核来说很可能只有一个线程。

一对一模型

对于直接支持线程的系统,一对一模型始终是最为简单的模型。一个用户线程唯一对应一个内核线程,但一个内核线程在用户态不一定有对应的线程存在。线程之间的并发是真正的并发。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pPYj78jk-1589783553282)(https://linotes.imliloli.com/assets/images/thread.11mdl.png)]

一般直接使用 API系统调用 创建的线程均为一对一的线程。

优点:

  • 和内核线程一致。一个线程因为某原因阻塞时,其他线程执行不会受到影响
  • 多线程程序在多处理器的系统上有更好的表现

缺点:

  • 由于许多操作系统限制了内核线程的数量,因此一对一线程会让用户的线程数量受到限制
  • 许多操作系统内核线程调度时,上下文切换的开销较大,导致用户线程的执行效率下降
多对一模型

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0BEtuNjn-1589783553282)(https://linotes.imliloli.com/assets/images/thread.m1mdl.png)]

优点:

  • 高效的上下文切换和几乎无限制的线程数量。

缺点:

  • 如果一个用户线程阻塞,则所有的线程都将无法执行,因为此时内核里的线程也随之阻塞了
  • 在多处理器系统上,处理器的增多对多对一模型的线程性能不会有明显的帮助。
多对多模型

多对多模型结合了多对一模型和一对一模型的特点,将多个用户线程映射到少数但不止一个内核线程上。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-scUSCm0F-1589783553284)(https://linotes.imliloli.com/assets/images/thread.mmmdl.png)]

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

11.3.3 多进程

多进程操作系统中,内存中同时保存着多个进程,力图实现 CPU 的最大使用率。

内核通过 进程调度程序 分时调度各个进程运行:

一个进程运行一段时间后,往往需要暂停,等待特定的系统资源。当它得到这些资源以后,才可以再次运行。一旦进程开始等待,进程调度程序就会把 CPU 移交给另一个更加需要的进程。Linux 会使用一些调度策略来保证所有进程公平使用系统资源。

而在单进程系统中,CPU 总是被闲置,等待的时间被白白浪费。

11.3.4 操作系统分类

根据进程与线程的设置,操作系统大致分为如下类型:

  • 单进程、单线程:MS-DOS
  • 多进程、单线程:多数 UNIX,LINUX
  • 多进程、多线程:Win32(Windows NT/2000/XP/~),Solaris 2.x,OS/2
    对多模型中,一个用户线程阻塞并不会使得所有的用户线程阻塞,因为此时还有别的线程可以被调度来执行。另外,多对多模型对用户线程的数量也没什么限制,在多处理器系统上,多对多模型的线程也能得到一定的性能提升,不过提升的幅度不如一对一模型高。

11.3.3 多进程

多进程操作系统中,内存中同时保存着多个进程,力图实现 CPU 的最大使用率。

内核通过 进程调度程序 分时调度各个进程运行:

一个进程运行一段时间后,往往需要暂停,等待特定的系统资源。当它得到这些资源以后,才可以再次运行。一旦进程开始等待,进程调度程序就会把 CPU 移交给另一个更加需要的进程。Linux 会使用一些调度策略来保证所有进程公平使用系统资源。

而在单进程系统中,CPU 总是被闲置,等待的时间被白白浪费。

11.3.4 操作系统分类

根据进程与线程的设置,操作系统大致分为如下类型:

  • 单进程、单线程:MS-DOS
  • 多进程、单线程:多数 UNIX,LINUX
  • 多进程、多线程:Win32(Windows NT/2000/XP/~),Solaris 2.x,OS/2
  • 单进程、多线程:VxWorks
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章