在计算机科学中,执行线程是可由调度器独立管理的最小程序指令序列,调度程序通常是操作系统的一部分。[1]线程和进程的实现因操作系统而异,但在大多数情况下,线程是进程的一个组成部分。多个线程可以存在于一个进程中,一个进程中的线程同时执行并共享内存等资源,而不同的进程不共享这些资源。特别是,同一个进程的线程可以在任何给定时间共享其可执行代码及其动态分配变量和非线程全局变量的值。
单处理器系统通常通过时间切片实现多线程:中央处理器(CPU)在不同的软件线程之间切换。这种上下文切换通常发生的非常频繁和迅速,以至于用户认为线程或任务是并行运行的。在多处理器或多核系统上,多线程可以并行执行,每个处理器或内核同时执行一个单独的线程;在具有硬件线程的处理器或内核上,单独的软件线程也可以由单独的硬件线程同时执行。
早在1967年,线程就以“任务”的名义出现在OS(操作系统)/360多道程序设计中(任务数可变的多道操作系统,MVT)。萨尔茨(1966年)把“线程”这个词归功于维克多·A·维索斯基。[2]许多现代操作系统的进程调度器直接支持分时线程和多处理器线程,操作系统内核允许程序员通过系统调用接口公开所需的功能来操作线程。一些线程的实现被称为内核线程,而轻量级进程(LWP)则是共享相同状态和信息的特定类型的内核线程。此外,当程序员在线程中使用定时器、信号或其他方法线程时,可以使用用户空间线程中断自己的操作,从而执行一种特殊的时间分片。
线程在以下几个方面不同于传统的多任务操作系统进程:
像WindowsNT和OS/2操作系统据说拥有便宜的线程和昂贵的进程;在其他操作系统中,除了地址空间交换的成本之外,没有太大的区别,如在某些体系结构(特别是x86)上,地址空间交换会导致转换后备缓冲区(TLB)刷新。
多线程主要出现在多任务操作系统中。多线程是一种广泛使用的编程和执行模型,它允许多个线程存在于一个进程的前后流程中。这些线程共享进程的资源,但是能够独立执行。这种多线程编程模型为开发人员提供了并发执行的抽象概念。多线程也可以应用于一个进程,以便实现多处理系统上的并行执行。
多线程应用程序具有以下优势:
多线程有以下缺点:
操作系统可以抢占调度线程,也可以协作调度线程。在多用户操作系统中,抢占式多线程是更广泛的使用方法,它通过上下文切换对执行时间进行更细粒度的控制。然而,抢先调度可能会在程序员没有预料到的时刻进行线程的上下文切换,从而导致锁保护、优先级反转或其他副作用。相反,协作多线程依赖于线程放弃对执行的控制,从而确保线程运行的顺利完成。如果协同多任务线程通过等待一个资源而阻塞,或者如果它在密集计算过程中不放弃对执行的控制而使其他线程处于空闲状态,这也可能会产生问题。
直到21世纪初,大多数台式计算机只有一个单核中央处理器(单核CPU),而不支持硬件线程,尽管这样,线程仍然被使用在这样的计算机上,因为线程之间的切换通常比全进程上下切换更快。2002年,英特尔以超线程技术为名,将同步多线程添加到了奔腾4处理器中;2005年,他们推出了双核奔腾D处理器,同时推出了双核Athlon 64 X2处理器。
嵌入式系统中的处理器对实时行为有更高的要求,它通过减少线程切换时间,如:通过为每个线程分配一个专用寄存器文件,而不是保存/恢复一个公共寄存器文件来支持多线程。
调度可以在内核级或用户级完成,多任务处理可以预先完成或协同完成。这就产生了一系列相关的概念。
在内核级别,一个进程包含一个或多个内核线程,它们共享进程的资源,例如内存和文件句柄——一个进程是一个资源单元,而一个线程是一个调度和执行单元。内核调度通常是预先统一完成的,或者采用不太常见地协作法完成。在用户级别,诸如运行时系统之类的进程,(尤其是在被抢先调度的情况下)本身可以调度多个执行线程,但如果它们不共享数据,通常被类似地称为进程(比如在Erlang中),[7]如果它们共享数据,则通常被称为(用户)线程。协作调度的用户线程称为纤程;不同的进程可以以不同地方式调度用户线程。用户线程可以由内核线程以各种方式执行(一对一、多对一、多对多)。术语“轻量级进程”指的是用户线程或内核机制,用于将用户线程调度到内核线程上。
进程是内核调度的“重量级”单元,因为创建、销毁和切换进程相对昂贵。进程拥有操作系统分配的资源,包括内存(用于代码和数据)、文件句柄、套接字、设备句柄、窗口和进程控制块。进程除非通过一些显式方法一般都是通过进程隔离操作进行隔离的,例如继承文件句柄或共享内存段,或者以共享方式映射相同文件(负责不共享地址空间或文件资源—请参见进程间通信)。由于创建或销毁进程必须通过获取或释放资源,所以成本相对较高。进程通常是抢占式多任务的,而且由于缓存刷新等问题导致进程切换成本相对较高,超出了上下文切换的基本成本。
内核线程是内核调度的“轻量级”单元。每个进程中至少存在一个内核线程。如果一个进程中存在多个内核线程,那么它们共享相同的内存和文件资源。如果操作系统的进程调度程序是抢占式的,则内核线程是抢占式多任务处理模式。内核线程除了堆栈、寄存器副本(包括程序计数器和线程本地存储器(如果有的话))之外不拥有其他资源,因此创建和销毁起来相对成本较低。线程切换也相对比较廉价:它需要上下文切换(保存和恢复寄存器和堆栈指针),但不改变虚拟内存,因此是缓存友好型的(使TLB有效)。内核可以为系统中的每个逻辑内核分配一个线程(因为如果支持多线程,每个处理器将自己分成多个逻辑内核,如果不支持多线程,则每个物理内核只支持一个逻辑内核),并且可以交换被阻塞的线程。然而,内核线程的交换时间比用户线程长得多。
线程有时在用户空间库中实现,因此称为用户线程。由于内核不知道它们,所以在用户空间中管理和调度它们。一些程序的实现将用户线程建立在几个内核线程之上,以便从多处理器机器(M:N模型)中获益。在本文中,术语“线程”(没有内核或用户限定符)默认指内核线程。由虚拟机实现的用户线程也称为绿色线程。用户线程通常创建和管理速度很快,但不能利用多线程或多处理模式,如果所有相关的内核线程都被阻塞,即便有一些用户线程准备运行,同样也会被阻塞。
纤程是一个更轻量级的协作调度单元:运行的纤程必须明确地“让步”,以允许另一个纤程运行,这使得它们的实现比内核或用户线程容易得多。纤程可以被安排在同一进程中的任何线程中运行。这允许应用程序通过管理自己的调度而不是依赖内核调度器(内核调度器可能不适合应用程序)来获得性能改进。像OpenMP这样的并行编程环境通常通过纤程来实现它们的任务。与纤程密切相关的是协同作用,区别在于协同作用是语言层次的构造,而纤程是系统层次的构造。
并发和数据结构
同一进程中的线程共享相同的地址空间。这允许并发运行的代码紧密耦合并方便地交换数据,而没有IPC的开销或复杂性。然而,当在线程间共享时,即使是简单的数据结构,如果需要一条以上的CPU指令来更新,那么它们也容易出现竞态情况:譬如两个线程可能最终试图同时更新数据结构,却发现数据结构发生了意外改变。这种由竟态条件引起的错误很难复制和隔离。
为了防止这种情况,线程应用编程接口(API)提供同步原语(如互斥锁)来锁定数据结构以防止并发访问。在单处理器系统上,运行在被锁定互斥锁中的线程必须休眠,以便触发上下文切换。在多处理器系统上,线程可以查询自旋锁中的互斥锁。对称多处理(SMP)系统中,这两种方法都可能降低性能,迫使处理器争夺内存总线,尤其是在锁定粒度的很好的情况下。
虽然线程似乎是顺序计算的一小步,但事实上,这是一个很大的进步。因为它们抛弃了顺序计算最基本和最吸引人的特性:可理解性、可预测性和决定论。线程作为一种计算模型具有极大的不确定性,程序员的工作就是消除这种不确定性。 — 《线程的的问题》, Edward A. Lee,加州大学伯克利分校,2006年[8]输入/输出和调度
用户线程或纤程实现通常完全在用户空间中。因此,同一进程内用户线程或纤程之间的上下文切换非常有效,因为它根本不需要与内核进行任何交互:上下文切换可以通过本地当前正在执行的用户线程或纤程所使用的CPU寄存器来保存,然后加载要执行的用户线程或纤程所需的寄存器来执行相应的操作。由于调度发生在用户空间,因此调度策略可以更容易地根据程序工作负载的需求进行调整。
然而,在用户线程(与内核线程相反)或纤程中使用阻塞系统调用可能会有问题。如果用户线程或纤程执行阻塞系统调用,进程中的其他用户线程和纤程将无法运行,直到系统调用返回。这个问题的一个典型例子就是在执行输入/输出时:大多数程序是为了同步执行输入/输出而编写的。当输入/输出操作启动时,会进行系统调用,并且直到输入/输出操作完成后才会返回。在此期间,整个进程被内核“阻塞”而无法运行,这使得同一进程中的其他用户线程和纤程无法执行。
这个问题的一个常见解决方案是提供一个输入/输出程序接口(API),它通过在内部使用非阻塞输入/输出来实现同步接口,并在输入/输出操作进行时调度另一个用户线程或纤程。可以为其他阻塞系统调用提供类似的解决方案。或者,可以编写程序来避免使用同步输入/输出或其他阻塞系统调用。
SunOS 4.x实现了轻量级流程或LWPs。NetBSD 2.x+和DragonFly BSD将LWPs实现为内核线程(1:1模型)。SunOS 5.2到SunOS 5.8以及NetBSD 2到NetBSD 4实现了两级模型,在每个内核线程上复用一个或多个用户级线程(M:N模型)。SunOS 5.9和更高版本以及NetBSD 5消除了用户线程支持,回到1:1模式。[9]FreeBSD 5实现了M:N模型。FreeBSD 6同时支持1:1和M:N,用户可以使用/etc/libmap.conf选择与给定程序比较匹配的模型。但FreeBSD 8不再支持M:N模型。
内核线程的使用通过将线程的一些最复杂的方面移入内核来简化用户代码。该程序不需要调度线程或显式生成处理器。用户代码可以用一种熟悉的过程风格编写,包括对阻塞API的调用,而不会饿死其他线程。然而,内核线程可能会在任何时候强制线程之间进行上下文切换,从而暴露出本来潜在的竞争风险和并发错误。在SMP系统上,因为内核线程实际上可以在独立的处理器上并行执行,所以这种情况会进一步恶化。
用户在与内核中可调度实体的1:1通信中创建的线程[10]是最简单的线程实现。操作系统 OS/2和Win32从一开始就使用这种方法,而在Linux上,通常是采用C类库实现这种方法的(通过NPTL或更老的Linux线程)。Solaris、NetBSD、FreeBSD、macOS和iOS也使用这种方法。
N:1模型意味着所有应用程序级线程需要映射到一个内核级调度实体;[10]内核不知道应用程序线程。这种方法可以促进上下文切换快速完成,此外,它甚至可以在不支持线程的简单内核上实现这个步骤。然而,一个主要的缺点是它不能从多线程处理器或多处理器(CPU)计算机上的硬件加速中获益:永远不会同时调度一个以上的线程。[10]例如:如果其中一个线程需要执行一个输入/输出(I/O)请求,那么整个进程将被阻塞,线程优势将无法使用。GNU可移植线程像状态线程一样使用用户级线程。
M:N将M个应用程序线程映射到N个内核实体[10]或“虚拟处理器”。这是内核级(“1:1”)和用户级(“1:1”)线程之间的折衷。一般来说,“M:N”线程系统内核和用户空间代码都需要更改,因而比内核或用户线程更难实现。在M:N实现中,线程库负责在可用的调度实体上调度用户线程;这使得线程的上下文切换非常快,因为它避免了系统调用。然而,这增加了复杂性和优先级反转的可能性,以及次优调度,同时用户域调度器和内核调度器之间没有广泛(且昂贵)的协调。
尽管一些操作系统或库为纤程提供了明确的支持,但它们可以在没有操作系统支持的情况下实现。
在20世纪60年代后期,IBM PL/I(F)逐渐支持多线程(称为多任务处理),这种支持在优化编译器和更高版本中继续。IBM企业编译程序引入了一个新的模型“线程”应用编程接口(API)。两个版本都不是PL/I标准的一部分。
许多编程语言在某种程度上支持线程。许多C和C++的实现支持线程,并提供对操作系统的本机线程接口API的访问。一些更高级(通常是跨平台)的编程语言,如Java、Python和.NET框架语言,在运行的过程中向开发人员公开线程在平台实现的差异。其他几种编程语言和语言扩展也试图从开发人员那里完全抽象出并发和线程的概念(Cilk、OpenMP、消息传递接口(MPI))。有些语言是特意为顺序并行而设计的(特别是使用GPU),不需要并发或线程(Ateji PX,CUDA)。
由于全局解释器锁的存在(GIL),一些解释编程语言具有支持线程和并发性但不支持线程并行执行的实现(例如,Ruby MRI for Ruby,CPython for Python)。GIL是解释器持有的互斥锁,可以防止解释器同时在两个或更多线程上解释应用程序代码,有效地限制了多核系统上的并行性。这主要限制了处理器绑定线程的性能,而对输入/输出绑定或网络绑定线程来说则没有太大限制。
解释编程语言的其他实现,例如使用线程扩展的Tcl,通过使用单元模型避免了GIL限制,其中数据和代码必须在线程之间显式地“共享”。在Tcl中,每个线程都有一个或多个解释器。
事件驱动编程硬件描述语言(如Verilog)有一个不同的线程模型,它支持极大量的线程(用于硬件建模)。
线程实现的标准化接口是POSIX线程(Pthreads),这是一组C函数库调用。操作系统供应商可以根据需要自由实现接口,但是应用程序开发人员应该能够在多个平台上使用相同的接口。包括Linux在内的大多数Unix平台都支持Pthreads。Microsoft Windows在这个过程中有自己的一套线程函数(h接口多线程- beginthread)。Java通过使用Java并发库(java.util.concurrent.)在主机操作系统上提供了又一个标准化接口。
多线程库提供了一个函数调用来创建一个新的线程,该线程将一个函数作为参数,然后创建并发线程,该线程开始运行传递的函数,并在函数返回时结束。线程库还提供同步功能,这使得使用互斥体、条件变量、关键部分、信号量、监视器和其他同步原语实现无竞争条件错误的多线程功能成为可能。
线程使用的另一个范例是线程池,其中在启动时创建一组线程,然后等待分配任务。当新任务到达时,它会醒来,完成任务并返回等待状态。这就避免了为执行的每项任务创建和销毁相对昂贵的线程弊端,并且将线程管理从应用程序开发人员手中拿走,留给更适合优化线程管理的库或操作系统。例如,中央调度(Grand Central Dispatch)和线程构建模块(Threading Building Blocks.)等框架。
在为数据并行计算而设计的CUDA等编程模型中,一组线程数组只需要使用其ID在内存中查找数据,从而并行运行相同的代码。本质上,应用程序的设计必须使每个线程在不同的内存段上执行相同的操作,以便可以并行操作并使用GPU架构。
^Lamport, Leslie (September 1979). "How to Make a Multiprocessor Computer That Correctly Executes Multiprocess Programs" (PDF). IEEE Transactions on Computers. C-28 (9): 690–691. doi:10.1109/tc.1979.1675439..
^Traffic Control in a Multiplexed Computer System, Jerome Howard Saltzer, Doctor of Science thesis, 1966, see footnote on page 20..
^Raúl Menéndez; Doug Lowe (2001). Murach's CICS for the COBOL Programmer. Mike Murach & Associates. p. 512. ISBN 978-1-890774-09-7..
^Stephen R. G. Fraser (2008-12-11). Pro Visual C++/CLI and the .NET 3.5 Platform. Apress. p. 780. ISBN 978-1-4302-1053-5..
^Peter William O'Hearn; R. D. Tennent (1997). ALGOL-like languages. 2. Birkhäuser Verlag. p. 157. ISBN 978-0-8176-3937-2..
^Single-Threading: Back to the Future? Sergey Ignatchenko, Overload #97.
^"Erlang: 3.1 Processes"..
^"The Problem with Threads", Edward A. Lee, UC Berkeley, January 10, 2006, Technical Report No. UCB/EECS-2006-1.
^"Multithreading in the Solaris Operating Environment" (PDF). 2002. Archived from the original (PDF) on February 26, 2009..
^Gagne, Abraham Silberschatz, Peter Baer Galvin, Greg (2013). Operating system concepts (9th ed.). Hoboken, N.J.: Wiley. pp. 170–171. ISBN 9781118063330..
^Marc Rittinghaus (23 December 2010). "System Call Aggregation for a Hybrid Thread Model" (PDF). p. 10..
^"User-Mode Scheduling". Microsoft Docs. 30 May 2018..
^CreateFiber, MSDN.
暂无