前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >《现代操作系统》—— 线程

《现代操作系统》—— 线程

原创
作者头像
VV木公子
修改2021-10-03 13:12:13
7960
修改2021-10-03 13:12:13
举报
文章被收录于专栏:TechBoxTechBox

前言

在传统操作系统中,每个进程有一个地址空间和一个控制线程。事实上,这几乎就是对进程的定义。不过,经常存在同一个地址空间中并行运行多个控制线程的情况,这些线程就像分离的进程(或者理解为微型进程)。线程和进程的区别是进程有独立的地址空间,而线程没有。一个进程内的地址空间是其内部的所有控制线程所共享的,这也是为什么开发者要了解资源竞争、加锁、解锁、死锁等线程问题的原因之一。本文将对线程进行系统性介绍。主要包括:线程出现的意义(即线程的作用)、经典线程模型、POSIX线程(pthread)、线程的实现。 一些名词:

  • 线程
  • 有限状态机
  • 线程表

线程的使用

为什么人们需要一个进程中再有一类进程(此处指线程)?有若干理由说明产生这些迷你进程(称为线程)的必要性:

  • 简化程序设计模型。
    • 在许多应用(进程)中同时发生着多种活动。其中某些活动随着时间的推移会被阻塞。通过将应用程序分解成可以并行运行的多个顺序线程,可以简化程序设计模型。
    • 举例:如果程序没有引入线程(可以理解成只有一个顺序线程)。在磁盘备份时,用户的鼠标和键盘操作将无法响应,直到备份工作完成,这显然是无法忍受的。另一个方法,为了霍德尔好的体验,可以让鼠标和键盘事件中断磁盘备份,但这样却引入了复杂的中断驱动程序设计模型。而如果引入线程的概念,程序拥有可多个线程,有的线程负责后台的磁盘备份,有的线程负责和用户交互。这样就简化了程序设计模型。
  • 线程更轻量、更容易创建。
    • 由于线程比进程更轻量,所以线程比进程更容易(即更快)创建,也更容易销毁。
    • 在许多系统中,通常创建一个线程比创建一个进程要快10~100倍。
    • 在有大量线程需要动态和快速修改时,这一特性是很有用的。
  • 线程可提升应用性能。
    • 从性能方面考量。如果应用具有大量的计算和大量的I/O处理,拥有多个线程允许这些活动并行重叠进行,从而提升程序执行速度。
    • 当然,如果多个线程都是CPU密集型的,那么并不能获得性能上的提升。
  • 线程提供了共享同一地址空间的能力。
    • 同一个进程中的多个线程拥有共享进程地址空间和所有可用数据的能力。这一能力是多进程模型无法表达的。因为每个进程具有不同的地址空间。
    • 对于绝大多数应用而言,共享同一地址空间中的数据的能力是必需的。

线程的作用

本节中以3个例子进行举例说明操作系统中引入(多)线程模型的作用。这3个例子分别是交互式程序、Web服务器、大数据处理程序。

(多线程)交互式程序

在交互式程序中,比如PC客户端上的字处理软件。如果该软件包含3个线程,一个交互线程可以及时用于及时响应用户的键盘鼠标等交互操作,一个格式化线程在后台负责文件的格式化工作,一个磁盘I/O线程用于定期把内存中的文件持久化到磁盘上。这样,每个线程负责各自的工作,一方面,多线程能够保证用户良好的交互体验,不至于因为磁盘I/O的阻塞而无法响应用户的交互。另一方面,多线程可以保证CPU和磁盘尽量高的利用率。不至于因为磁盘I/O而让CPU空转。 相反,如果我们的交互式程序是单线程,依旧以PC客户端上的字处理软件为例。那么在磁盘I/O时,来自键盘和鼠标的命令就会被忽略,直到I/O工作完成。这种交互体验是很差的。另一个方法是,为了获得更好的性能和体验,可以让键盘和鼠标时间中断磁盘I/O,但这样却引入了复杂的中断驱动程序设计模型。还不如使用多线程模型。所以,相对于交互体验差的单线程模型和复杂的中断驱动程序设计模型,多线程模型是目前程序设计中最好的选择。

(多线程)Web服务器

下面再考察另一个说明线程作用的例子,万维网Web服务器。如果该软件包含2类线程,一类是分发线程,一类是工作线程。分发线程只有一个,工作线程可能有多个。分发线程从网络中读取请求,然后对请求进行检查后分派给一个空转的(即被阻塞的)工作线程。分派线程唤醒睡眠的工作线程,将它从阻塞状态转为就绪状态。

工作线程被唤醒后,检查内存缓存中是否有该请求的数据。如果内存中能命中,则直接把缓存的数据返回给请求者,然后该工作线程再次进入睡眠阻塞状态。如果内存汇总不能命中,则该工作线程开始进行磁盘I/O操作,并阻塞知道该I/O操作完成。当上述工作线程阻塞在磁盘操作上时,为了完成更多的工作,分发线程可能挑选另一个空闲的(阻塞态)工作线程处理其他用户的请求。工作线程通常有多个,其数量通常与请求数正相关。 相反,如果我们的Web服务器是单线程,即没有多线程的场景下。一种可能的实现方式是:Web服务器的主循环获得请求、检查请求。在等待磁盘I/O时,服务器就空转,并且无法处理到来的其他请求。这样的后果是每台Web服务器单位时间内能处理的请求数量很少。因为在阻塞式的磁盘I/O使CPU空转,直到磁盘I/O完成。可见,和单线程Web服务器相比,多线程Web服务器较好的改善了Web服务器的性能,而且每个线程是按通常方式顺序编程的。 当然,单线程Web服务器还可以使用非阻塞磁盘I/O的方式实现。这类设计叫做有限状态机。其设计思想大概是:服务器维护了一个工作表,其中记录了每个请求的状态,请求到来时,如果需要磁盘I/O,服务器不会傻傻的等待,而是去处理下一个事件,下一个事件可能是一个新的网络请求,也可能是磁盘I/O对先前某个非阻塞操作的回答。如果是新工作,就开始处理。如果是磁盘的回答,就从表格中取出该请求对应的信息,并处理该回答。对于非阻塞磁盘I/O而言,这种回答多数会以信号或中断的形式出现

处理大量数据

关于多线程作用的第三个例子是处理大量数据的场景。如果不使用多线程,通常的操作是从磁盘读取一部分数据到内存,对其进行计算后再写出数据到磁盘。然后在读取另一部分数据进行计算。这里的问题是在进行数据读入和写出时,如果使用阻塞系统调用,进程会被阻塞,即CPU就是在空转。在有大量计算需要处理的时候,让CPU空转显然是资源的浪费。

使用多线程的话,进程可以拥有3个线程,分别是输入线程、处理线程、输出线程。输入线程从磁盘把数据读入到输入缓冲区,处理线程从输入缓冲区读取数据进行处理,然后把处理完成的数据写到输出缓冲区,输出线程把输出缓冲区的内容写入到磁盘。这种操作是典型的生产者和消费者问题

多线程使得顺序进程的思想得以保留下来,这种顺序进程(线程)阻塞了系统调用(如磁盘I/O),但是仍旧实现了并行性。

image.png
image.png

经典线程模型

线程构成

进程拥有一个执行的线程,通常简写为线程(thread)。线程中有:

  • 程序计数器(PC):用于记录接下来要执行哪一条指令
  • 寄存器:用来保存线程当前的工作变量
  • 堆栈:用来记录函数执行历史,其中每一帧记录一个已调用的但还没有从中返回的过程(函数)

尽管,线程必须要在某个进程中执行,但线程和进程是不同的概念,并且可以分别处理。进程用于把资源集中到一起,线程则是在CPU上被调度执行的实体。

线程给进程环境增加了一项内容,即在同一个进程环境中,允许彼此之前与较大独立性的多个线程执行。在同一个进程中并行运行多个线程,是对同一台计算机上并行运行多个进程的模拟

多个线程共享同一个地址空间和其他资源。 多个进程共享物理内存、磁盘、打印机和其他资源。 由于线程具有进程的某些特性,所以线程有时候被称为轻量级进程(lightweight process)。 多线程术语通常用来描述在一个进程中允许多个线程的情形,CPU已经有直接硬件支持多线程,并且允许线程切换在纳秒级完成。

当多线程进程在单CPU系统上运行时,线程轮流运行。这种情况下所谓的并行其实是伪并行。就像进程的多道程序设计系统的实现一样,通过在多个进程之间来回切换,制造了多个进程并行运行的假象。多线程也是类似,CPU在多个线程之间切换,制造了多个线程并行运行的假象。但实际上在一个由3个计算密集型线程的进程中,在一个CPU上并行运行的线程实际上只得到了真实CPU的三分之一的速度和资源。

线程状态

每个线程有自己的状态。和传统进程一样(即只有一个线程的进程),线程也有状态,线程可以处于若干种状态中的任何一个:运行、阻塞、就绪、终止。线程状态之间的转换和进程状态的转换是一样的。

线程堆栈

每个线程有自己的堆栈。每个线程的堆栈有一帧,供各个被调用但是还没有从中返回的过程(函数)使用。在该栈帧中存放了相应过程的局部变量以及过程调用完成之后使用的返回地址。通常每个线程会调用不同的过程,从而偶一个格子不同的过程调用历史,这也是为什么每个线程都有自己的堆栈的原因。

POSIX 线程

POSIX 简介

在介绍POSIX线程之前,有必要先介绍一下POSIX。以下来源于wikipedia

POSIX译为可移植操作系统接口(Portable Operating System Interface,缩写为POSIX)是IEEE为要在各种UNIX操作系统上运行软件,而定义API的一系列互相关联的标准的总称,其正式称呼为IEEE Std 1003,而国际标准名称为ISO/IEC 9945。此标准源于一个大约开始于1985年的项目。POSIX这个名称是由理查德·斯托曼(RMS)应IEEE的要求而提议的一个易于记忆的名称。它基本上是Portable Operating System Interface(可移植操作系统接口)的缩写,而X则表明其对Unix API的传承。

虽然POSIX为各种UNIX系统定义的标准,但Linux基本上逐步实现了POSIX兼容,但并没有参加正式的POSIX认证。微软的Windows NT也声称部分实现了POSIX标准。所以POSIX基本上是所有主流操作系统都遵守的协议标准。

当前的POSIX主要分为四个部分[2]:

  • Base Definitions
  • System Interfaces、
  • Shell and Utilities
  • Rationale

POSIX1.1标准

以下是POSIX1.1的标准:

  • 1003.0
    • 管理POSIX开放式系统环境(OSE)。IEEE在1995年通过了这项标准。ISO的版本是ISO/IEC 14252:1996。
  • 1003.1
    • 被广泛接受、用于源代码级别的可移植性标准。1003.1提供一个操作系统的C语言应用编程接口(API)。IEEE和ISO已经在1990年通过了这个标准,IEEE在1995年重新修订了该标准。
  • 1003.1b
    • 一个用于实时编程的标准(以前的P1003.4或POSIX.4)。这个标准在1993年被IEEE通过,被合并进ISO/IEC 9945-1。
  • 1003.1c
    • 一个用于线程(在一个程序中当前被执行的代码段)的标准。以前是P1993.4或POSIX.4的一部分,这个标准已经在1995年被IEEE通过,归入ISO/IEC 9945-1:1996。
  • 1003.1g
    • 一个关于协议独立接口的标准,该接口可以使一个应用程序通过网络与另一个应用程序通讯。1996年,IEEE通过了这个标准。
  • 1003.2
    • 一个应用于shell和工具软件的标准,它们分别是操作系统所必须提供的命令处理器和工具程序。1992年IEEE通过了这个标准。ISO也已经通过了这个标准(ISO/IEC 9945-2:1993)。
  • 1003.2d
    • 改进的1003.2标准。
  • 1003.5
    • 一个相当于1003.1的Ada语言的API。在1992年,IEEE通过了这个标准。并在1997年对其进行了修订。ISO也通过了该标准。
  • 1003.5b
    • 一个相当于1003.1b(实时扩展)的Ada语言的API。IEEE和ISO都已经通过了这个标准。ISO的标准是ISO/IEC 14519:1999。
  • 1003.5c
    • 一个相当于1003.1q(协议独立接口)的Ada语言的API。在1998年,IEEE通过了这个标准。ISO也通过了这个标准。
  • 1003.9
    • 一个相当于1003.1的FORTRAN语言的API。在1992年,IEEE通过了这个标准,并于1997年对其再次确认。ISO也已经通过了这个标准。
  • 1003.10
    • 一个应用于超级计算应用环境框架(Application Environment Profile,AEP)的标准。在1995年,IEEE通过了这个标准。
  • 1003.13
    • 一个关于应用环境框架的标准,主要针对使用POSIX接口的实时应用程序。在1998年,IEEE通过了这个标准。
  • 1003.22
    • 一个针对POSIX的关于安全性框架的指南。
  • 1003.23
    • 一个针对用户组织的指南,主要是为了指导用户开发和使用支持操作需求的开放式系统环境(OSE)框架
  • 2003
    • 针对指定和使用是否符合POSIX标准的测试方法,有关其定义、一般需求和指导方针的一个标准。在1997年,IEEE通过了这个标准。
  • 2003.1
    • 这个标准规定了针对1003.1的POSIX测试方法的提供商要提供的一些条件。在1992年,IEEE通过了这个标准。
  • 2003.2
    • 一个定义了被用来检查与IEEE 1003.2(shell和工具API)是否符合的测试方法的标准。在1996年,IEEE通过了这个标准。

除了1003和2003家族以外,还有几个其它的IEEE标准,例如1224和1228,它们也提供开发可移植应用程序的API。

pthread

前面了解了POSIX,那么基于POSIX的线程又是什么呢?其实是一种IEEE对线程定义的标准。

为了实现可移植的线程程序,IEEE在IEEE标准1003.1c中定义了线程的标准。它定义的线程包就叫pthread。所以pthread是基于某种可移植的线程标准(IEEE的线程标准)定义的线程包。这也是为什么大部分UNIX系统都支持pthread的原因。因为它们都支持这一标准。这个标准定义了超过60个函数调用,主要的函数如下:

线程调用

描述

pthread_create

创建一个新线程

pthread_exit

结束调用的线程

pthread_join

等待一个特定的线程退出

pthread_yield

释放CPU来运行另外一个线程

pthread_attr_init

创建并初始化一个线程的属性结构

pthread_attr_destroy

删除一个线程的属性结构

pthread_yield的作用

有时候一个线程逻辑上没有阻塞,但感觉上已经运行了足够长时间并且希望把CPU给另外一个线程运行。这是可以通过调用pthread_yield实现。而进程没有这种调用,因为系统假设进程间是“竞争性”关系,每一个进程都是自私的,都希望获得所有的CPU时间。所以操作系统没有给进程提供这种功能。但因同一个进程中的线程可以同时工作,并且同一个进程中的代码都是同一个开发者或同一个组织编写的,他们之间整体上是“合作性”关系。有时程序员希望他们能互相给对方一些机会去使用CPU时间片,所以操作系统给线程暴露了pthread_yield这个接口,把权利交给开发者。

线程的实现

每个进程都有一个执行的线程(thread)。线程中主要包括:

  • 程序计数器(PC):用来记录接下来要执行哪一个汇编指令
  • 堆栈:用于记录指令执行历史。当然还有堆栈指针,用于指向堆栈的栈顶、栈底
  • 寄存器:用来保存线程当前的工作变量
  • 状态:线程当前的状态包括:运行、就绪、阻塞

有2种主要的方法实现线程包:在用户空间中和内核中。这两种方法互有利弊,混合实现也是可能的。

在用户空间中实现线程

把整个线程包放在用户空间,内核对线程包一无所知。从内核角度考虑,还是按单线程进程的方式管理。在用户空间管理线程时,每个进程需要有其专用的线程表(thread table),线程表用来跟踪进程中的线程。这些表和内核中的进程表类似,仅记录线程的属性。如:线程的程序计数器、堆栈、寄存器、状态等。当一个线程转换到就绪状态或阻塞状态时,在该线程表中存放重新启动该线程所需要的信息,与内核在进程表中存放进程的信息几乎一样。

优点

  • 用户级线程包可以在不支持线程的操作系统上实现。过去所有的操作系统属于这个范围,但现在不是了。
  • 用户级线程允许每个进程自己定制调度算法。例如在某些拥有来及收集线程的应用程序不用担心线程会在不合适的时刻停止。
  • 用户级线程具有较好的可扩展性。因为在内核空间中内核线程需要一些固定表格空间和堆栈空间,当内核线程数量非常大时,所需要的空间和是很大的。所以在用户空间中实现线程,比在内核空间实现线程扩展性好。

缺陷

  • 不易实现阻塞系统调用。
  • 发生缺页中断时会阻塞这个进程。如果有一个线程引起缺页中断,内核不知道有线程的存在,通常会阻塞整个进程知道磁盘I/O完成,尽管理论上其他的线程是可运行的。
  • 如果一个线程开始运行,那么该进程中的其他线程就不能运行。因为在进程内没有时钟中断,所以不能用轮转调度的方式调度线程。
image.png
image.png

如下图2-16a所示,用户级线程在一个运行时系统的上层运行,该运行时系统是一个管理线程的过程的集合。这些过程包括就是前面介绍的:pthread_create、pthread_exit、pthread_join、pthread_yield。当然集合中还有这4种过程之外的其他过程。

image.png
image.png

在内核中实现线程

上面介绍了在用户空间中实现线程的利弊,现在来看下将线程的实现放到内核中的情况。如上图2-16b所示,内核中实现线程不再需要运行时系统,但仍需要线程表,只是这个线程表不在用户空间中,而是存在于内核中,和用户空间中的线程表一样,也是用来记录系统中所有线程的使用情况。当某个线程希望创建或销毁线程时,他需要执行一个系统调用,这个系统调用进而会更新线程表,已完成线程的创建或销毁。

在内核中实现的线程,所有能够阻塞线程的调用都以系统调用的形式实现,代价是可观的。因为一个线程进入阻塞态时,内核需要选择运行另一个就绪线程,这个就绪线程可能属于当前进程,也可能属于另一个进程。如果运行另一个进程中的线程,还涉及到进程的切换,代价自然较大。而在用户级线程的实现中,运行时系统始终运行自己进程的线程,直到内核剥夺它的CPU——切换进程。

因为内核创建和销毁线程的代价大。所以某些系统并不会真正的销毁线程,只是把它标记为不可运行,其依旧在内核和线程表中,当要创建一个线程时,就启用这个之前被标记为不可运行的线程。这样可以节省创建新线程的开销。同样的操作也可以在用户级线程实现,但因为用户级线程管理代价小,所以这样做的收益不大。

在用户空间和内核中混合实现

鉴于以上两种线程实现方式的优缺点,人们研究了用户级线程和内核级线程相结合的实现方式。一种方法是使用内核级线程,然后将用户级线程和内核级线程多路复用。如下图:

image.png
image.png

采用这种方法,内核只识别和调度内核级线程,其中一些内核级线程会被多个用户级线程多路复用。编程人员可以决定有多少个内核级线程和多少个用户级线程彼此多路复用。这一模型灵活度高。

弹出式线程

一个消息或请求的到达导致系统创建一个处理该消息的线程,这种线程称为弹出式线程。弹出式线程的好处是:由于线程相当新,没有历史——没有必须存储的寄存器、堆栈等。每个线程彼此之前都相同,这样有可能快速创建这类线程。所以消息和请求到达到该是处理消息和请求的时间非常短。

进程 VS 线程

尽管文章开头处把线程被称为“轻量级”进程。但线程和进程的概念是不同的,并且可以分别处理。进程用于把资源集中到一起,而线程则是在CPU上被调度的实体。 进程有独立的地址空间,而线程没有,同一进程内的所有线程共享进程的可访问的地址空间。 进程之间是资源竞争性关系,线程之间是任务协作性关系。 创建进程比创建线程需要消耗更多的CPU资源和时间。 切换进程比切换线程需要消耗更多的CPU资源和时间。 多个线程共享同一个地址空间和其他资源。 多个进程共享物理内存、磁盘、打印机和其他资源。 由于线程具有进程的某些特性,所以线程有时候被称为轻量级进程进程的内容

  • 地址空间
  • 全局变量
  • 打开文件
  • 子进程
  • 即将发生的定时器
  • 信号与信号处理程序
  • 账户信息

线程的内容

  • 程序计数器
  • 寄存器
  • 堆栈
  • 状态

文/VV木公子(原创作者)

PS:如非特别说明,所有文章均为原创作品,著作权归作者所有,转载请联系作者获得授权,并注明出处!

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 线程的使用
    • 线程的作用
      • (多线程)交互式程序
      • (多线程)Web服务器
      • 处理大量数据
  • 经典线程模型
    • 线程构成
      • 线程状态
        • 线程堆栈
        • POSIX 线程
          • POSIX 简介
            • POSIX1.1标准
              • pthread
                • pthread_yield的作用
            • 线程的实现
              • 在用户空间中实现线程
                • 优点
                • 缺陷
              • 在内核中实现线程
                • 在用户空间和内核中混合实现
                • 弹出式线程
                • 进程 VS 线程
                领券
                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档