https://www.cs.uic.edu/~jbell/CourseNotes/OperatingSystems/4_Threads.html
参考文献:
- Abraham Silberschatz,Greg Gagne和Peter Baer Galvin,“操作系统概念,第9版”,第4章
4.1概述
- 甲线程是CPU利用率的基本单元,包括一个程序计数器,堆栈,和一组寄存器中,(和线程ID。)
- 传统(重量级)进程具有单个控制线程 - 有一个程序计数器,以及可在任何给定时间执行的一系列指令。
- 如图4.1所示,多线程应用程序在单个进程中具有多个线程,每个线程都有自己的程序计数器,堆栈和寄存器集,但共享公共代码,数据和某些结构(如打开文件)。
图4.1 - 单线程和多线程进程
4.1.1动机
- 只要进程有多个任务独立于其他任务执行,线程在现代编程中非常有用。
- 当其中一个任务可能阻塞时,尤其如此,并且希望允许其他任务在不阻塞的情况下继续进行。
- 例如,在文字处理器中,后台线程可以在前台线程处理用户输入(击键)时检查拼写和语法,而第三个线程从硬盘驱动器加载图像,第四个线程对正在编辑的文件进行定期自动备份。
- 另一个例子是Web服务器 - 多个线程允许同时满足多个请求,而不必按顺序服务请求或为每个传入请求分离单独的进程。(后者是在开发线程概念之前这样做的事情。守护进程会在一个端口上监听,为每个要处理的传入请求分叉一个子进程,然后再回到侦听端口。)
图4.2 - 多线程服务器架构
4.1.2好处
- 多线程有四大类优点:
- 响应性 - 一个线程可以提供快速响应,而其他线程被阻塞或减慢进行密集计算。
- 资源共享 - 默认情况下,线程共享公共代码,数据和其他资源,这允许在单个地址空间中同时执行多个任务。
- 经济 - 创建和管理线程(以及它们之间的上下文切换)比为进程执行相同的任务要快得多。
- 可伸缩性,即多处理器体系结构的利用 - 单线程进程只能在一个CPU上运行,无论有多少可用,而多线程应用程序的执行可能在可用处理器之间分配。(请注意,当有多个进程争用CPU时,即当负载平均值高于某个特定阈值时,单线程进程仍然可以从多处理器体系结构中受益。)
4.2多核编程
- 计算机体系结构的最新趋势是在单个芯片上生产具有多个核心或CPU的芯片。
- 在传统的单核芯片上运行的多线程应用程序必须交错线程,如图4.3所示。但是,在多核芯片上,线程可以分布在可用内核上,从而实现真正的并行处理,如图4.4所示。
图4.3 - 单核系统上的并发执行。
图4.4 - 多核系统上的并行执行
- 对于操作系统,多核芯片需要新的调度算法以更好地利用可用的多个核。
- 随着多线程变得越来越普遍和越来越重要(数千而不是数十个线程),CPU已被开发用于支持硬件中每个核心更多的同步线程。
4.2.1编程挑战(新部分,相同内容?)
- 对于应用程序员来说,有五个领域,多核芯片带来了新的挑战:
- 识别任务 - 检查应用程序以查找可以同时执行的活动。
- 平衡 - 查找同时运行的任务,提供相同的价值。即不要浪费一些线程来完成琐碎的任务。
- 数据拆分 - 防止线程相互干扰。
- 数据依赖性 - 如果一个任务依赖于另一个任务的结果,则需要同步任务以确保以正确的顺序进行访问。
- 测试和调试 - 在并行处理情况下本身就更加困难,因为竞争条件变得更加复杂和难以识别。
4.2.2并行类型(新)
从理论上讲,有两种不同的工作负载并行化方法:
- 数据并行性在多个核(线程)之间划分数据,并在数据的每个子集上执行相同的任务。例如,将大图像分成多个片段并对不同核心上的每个片段执行相同的数字图像处理。
- 任务并行性划分要在不同核心之间执行的不同任务并同时执行它们。
在实践中,任何程序都不会仅仅由这些中的一个或另一个划分,而是通过某种混合组合。
4.3多线程模型
- 在现代系统中有两种类型的线程需要管理:用户线程和内核线程。
- 内核上支持用户线程,没有内核支持。这些是应用程序员将在其程序中添加的线程。
- 操作系统本身的内核支持内核线程。所有现代操作系统都支持内核级线程,允许内核同时执行多个同时任务和/或服务多个内核系统调用。
- 在特定实现中,必须使用以下策略之一将用户线程映射到内核线程。
4.3.1多对一模型
- 在多对一模型中,许多用户级线程都映射到单个内核线程。
- 线程管理由用户空间中的线程库处理,这非常有效。
- 但是,如果进行了阻塞系统调用,那么即使其他用户线程能够继续,整个进程也会阻塞。
- 由于单个内核线程只能在单个CPU上运行,因此多对一模型不允许在多个CPU之间拆分单个进程。
- Solaris和GNU可移植线程的绿色线程在过去实现了多对一模型,但现在很少有系统继续这样做。
图4.5 - 多对一模型
4.3.2一对一模型
- 一对一模型创建一个单独的内核线程来处理每个用户线程。
- 一对一模型克服了上面列出的问题,涉及阻止系统调用和跨多个CPU分离进程。
- 但是,管理一对一模型的开销更大,涉及更多开销和减慢系统速度。
- 此模型的大多数实现都限制了可以创建的线程数。
- 从95到XP的Linux和Windows实现了线程的一对一模型。
图4.6 - 一对一模型
4.3.3多对多模型
- 多对多模型将任意数量的用户线程复用到相同或更少数量的内核线程上,结合了一对一和多对一模型的最佳特性。
- 用户对创建的线程数没有限制。
- 阻止内核系统调用不会阻止整个进程。
- 进程可以分布在多个处理器上。
- 可以为各个进程分配可变数量的内核线程,具体取决于存在的CPU数量和其他因素。
图4.7 - 多对多模型
- 多对多模型的一个流行变体是双层模型,它允许多对多或一对一操作。
- IRIX,HP-UX和Tru64 UNIX使用双层模型,Solaris 9之前的Solaris也是如此。
图4.8 - 两级模型
4.4线程库
- 线程库为程序员提供了用于创建和管理线程的API。
- 线程库可以在用户空间或内核空间中实现。前者涉及仅在用户空间内实现的API函数,没有内核支持。后者涉及系统调用,并且需要具有线程库支持的内核。
- 今天使用了三个主要的线程库:
- POSIX Pthreads - 可以作为用户或内核库提供,作为POSIX标准的扩展。
- Win32线程 - 在Windows系统上作为内核级库提供。
- Java线程 - 由于Java通常在Java虚拟机上运行,因此线程的实现基于JVM运行的任何操作系统和硬件,即Pthreads或Win32线程,具体取决于系统。
- 以下部分将演示在所有三个系统中使用线程来计算单独线程中从0到N的整数之和,并将结果存储在变量“sum”中。
4.4.1 Pthreads
- POSIX标准(IEEE 1003.1c)定义了pThreads 的规范,而不是实现。
- pThreads可在Solaris,Linux,Mac OSX,Tru64和Windows的公共域共享软件上使用。
- 全局变量在所有线程之间共享。
- 在继续之前,一个线程可以等待其他线程重新加入。
- pThreads在指定函数中开始执行,在本例中为runner()函数:
图4.9
新
4.4.2 Windows线程
- 与pThreads相似。检查代码示例以查看差异,这些差异主要是语法和命名法:
[图片上传中...(image-f2b26e-1547818800924-3)]
图4.11
4.4.3 Java线程
- 所有Java程序都使用Threads - 甚至是“常见的”单线程程序。
- 新线程的创建需要实现Runnable接口的对象,这意味着它们包含一个方法“public void run()”。Thread类的任何后代自然都会包含这样的方法。(实际上,必须重写/提供run()方法,以使线程具有任何实际功能。)
- 创建线程对象不会启动线程运行 - 为此,程序必须调用Thread的“start()”方法。Start()为Thread分配并初始化内存,然后调用run()方法。(程序员不直接调用run()。)
- 因为Java不支持全局变量,所以必须将Threads传递给共享Object的引用才能共享数据,在本例中为“Sum”对象。
- 请注意,JVM在本机操作系统之上运行,并且JVM规范未指定用于将Java线程映射到内核线程的模型。此决定依赖于JVM实现,可能是一对一,多对多或多对一..(在UNIX系统上,JVM通常使用PThreads,而在Windows系统上,它通常使用Windows线程。)
图4.12
4.5隐式线程(可选)
将解决上面4.2.1节中列出的编程挑战的负担从应用程序编程器转移到编译器和运行时库。
4.5.1线程池
- 每次需要创建新线程然后在完成时删除它可能效率低下,并且还可能导致创建非常大(无限)的线程数。
- 另一种解决方案是在进程首次启动时创建多个线程,并将这些线程放入线程池中。
- 根据需要从池中分配线程,并在不再需要时返回池。
- 如果池中没有可用的线程,则该进程可能必须等到一个可用。
- 线程池中可用的(最大)线程数可以由可调参数确定,可能动态地响应于改变的系统负载。
- Win32通过“PoolFunction”函数提供线程池。Java还通过java.util.concurrent包为线程池提供支持,Apple支持Grand Central Dispatch架构下的线程池。
4.5.2 OpenMP
- OpenMP是一组可用于C,C ++或FORTRAN程序的编译器指令,用于指示编译器在适当的情况下自动生成并行代码。
- 例如,指令:
#pragma omp parallel { / 这里有一些并行代码 / }会导致编译器创建与机器具有可用内核一样多的线程(例如,在四核机器上为4),并在每个线程上运行并行代码块(称为并行区域)。
- 另一个示例指令是“ #pragma omp parallel for ”,这会导致紧随其后的for循环被并行化,从而在可用内核之间划分迭代。
4.5.3 Grand Central Dispatch,GCD
- GCD是Apple OSX和iOS操作系统上可用的C和C ++的扩展,用于支持并行性。
- 与OpenMP类似,GCD的用户通过在开放大括号之前放置一个克拉来定义要串行或并行执行的代码块,即^ {printf(“我是一个块。\ n”); }
- GCD通过将块放在多个调度队列中的一个上来调度块。
- 放置在串行队列上的块将逐个删除。在上一个块完成之前,无法删除下一个块以进行调度。
- 有三个并发队列,大致相当于低,中或高优先级。块也会逐个从这些队列中删除,但是可以删除和分派几个块,而不必等待其他队列先完成,具体取决于线程的可用性。
- 内部GCD管理POSIX线程池,其大小可能根据负载条件而波动。
4.5.4其他方法
还有其他几种可用的方法,包括Microsoft的线程构建模块(TBB)和其他产品,以及Java的util.concurrent包。
4.6线程问题
4.6.1 fork()和exec()系统调用
- 问:如果一个线程分叉,是整个进程被复制,还是新进程是单线程的?
- 答:取决于系统。
- 答:如果新进程立即执行,则无需复制所有其他线程。如果没有,则应复制整个过程。
- 答:许多版本的UNIX为此提供了多个版本的fork调用。
4.6.2信号处理
- 问:当多线程进程收到信号时,该信号应传递到哪个线程?
- 答:有四个主要选择:
- 将信号传递给信号所适用的线程。
- 将信号传递给过程中的每个线程。
- 将信号传递给过程中的某些线程。
- 分配特定线程以接收进程中的所有信号。
- 最佳选择可能取决于涉及哪个特定信号。
- UNIX允许各个线程指示它们接受哪些信号以及它们忽略哪些信号。但是,信号只能传递给一个线程,这通常是接受该特定信号的第一个线程。
- UNIX提供了两个独立的系统调用:kill(pid,signal)和pthread_kill(tid,signal),分别用于向进程或特定线程传递信号。
- Windows不支持信号,但可以使用异步过程调用(APC)模拟它们。APC被传递到特定线程,而不是进程。
4.6.3线程取消
- 不再需要的线程可能会被另一个线程以两种方式之一取消:
- 异步取消立即取消线程。
- 延迟取消设置一个标志,指示线程在方便时应自行取消。然后由取消的线程定期检查此标志,并在看到标志设置时很好地退出。
- 异步取消(共享)资源分配和线程间数据传输可能会有问题。
4.6.4线程局部存储(4.4.5特定于线程的数据)
- 大多数数据在线程之间共享,这是首先使用线程的主要好处之一。
- 但是,有时线程也需要特定于线程的数据。
- 大多数主要线程库(pThreads,Win32,Java)都支持特定于线程的数据,称为线程本地存储或TLS。请注意,这更像是静态数据而不是局部变量,因为它在函数结束时不会停止存在。
4.6.5调度程序激活
- 许多线程实现提供虚拟处理器作为用户线程和内核线程之间的接口,特别是对于多对多或双层模型。
- 这个虚拟处理器被称为“轻量级进程”,LWP。
- LWP和内核线程之间存在一对一的对应关系。
- 可用的内核线程数(以及因此LWP的数量)可以动态地改变。
- 应用程序(用户级线程库)将用户线程映射到可用的LWP。
- 内核线程由OS调度到真实处理器上。
- 当某些事件发生时(例如一个即将阻塞的线程),内核通过upcall与内核进行通信,该upcall由一个upcall处理程序在线程库中处理。上行调用还为上行处理程序提供了一个新的LWP,然后它可以用来重新安排即将被阻止的用户线程。当线程被解除阻塞时,操作系统也会发出upcall,因此线程库可以进行适当的调整。
- 如果内核线程阻塞,则LWP阻塞,阻塞用户线程。
- 理想情况下,应该至少有可用的LWP可用于同时阻塞的内核线程。否则,如果所有LWP都被阻止,那么用户线程将不得不等待一个LWP可用。
图4.13 - 轻量级过程(LWP)
4.7操作系统示例(可选)
4.7.1 Windows XP线程
- Win32 API线程库支持一对一线程模型
- Win32还提供光纤库,支持多对多模型。
- Win32线程组件包括:
- 线程ID
- 寄存器
- 用户模式中使用的用户堆栈,以及内核模式中使用的内核堆栈。
- 各种运行时库和动态链接库(DLL)使用的专用存储区域。
- Windows线程的关键数据结构是ETHREAD(执行线程块),KTHREAD(内核线程块)和TEB(线程环境块)。ETHREAD和KTHREAD结构完全存在于内核空间中,因此只能由内核访问,而TEB位于用户空间内,如图4.10所示:
图4.14 - Windows线程的数据结构
4.7.2 Linux线程
- Linux不区分进程和线程 - 它使用更通用的术语“任务”。
- 传统的fork()系统调用完全复制了一个进程(任务),如前所述。
- 另一个系统调用clone()允许父和子任务之间的不同程度的共享,由下表中显示的标志控制:
| 旗 | 含义 |
| CLONE_FS | 文件系统信息是共享的 |
| CLONE_VM | 共享相同的内存空间 |
| CLONE_SIGHAND | 信号处理程序是共享的 |
| CLONE_FILES | 打开的文件集是共享的 |
- 调用没有设置标志的clone()等同于fork()。使用CLONE_FS,CLONE_VM,CLONE_SIGHAND和CLONE_FILES调用clone()等同于创建线程,因为所有这些数据结构都将被共享。
- Linux使用结构task_struct实现这一点,该结构实质上为任务资源提供了间接级别。如果未设置标志,则复制结构指向的资源,但如果设置了标志,则仅复制指向资源的指针,因此共享资源。(想想深层复制与OO编程中的浅层复制。)
- (从第9版中删除) Linux的几个发行版现在支持NPTL(Native POXIS Thread Library)
- 符合POSIX标准。
- 支持SMP(对称多处理),NUMA(非统一内存访问)和多核处理器。
- 支持数百到数千个线程。