正如进程代表虚拟计算机一样,线程
抽象表示一个虚拟处理器。
所以线程是一种抽象。
抽象降低了复杂性。因此,第一个问题是线程解决什么问题。第二个问题是如何实施。
关于第一个问题:线程使实现多任务更容易。这背后的主要思想是,如果每一项任务都可以分配给唯一的员工,那么多任务处理就没有必要了。实际上,就目前而言,可以进一步概括这个定义,并说线程抽象代表一个虚拟工作者。
Now, imagine you have a robot that you want to give multiple tasks. Unfortunately, it can only execute a single, step by step task description. Well, if you want to make it multitask, you can try creating one big task description by interleaving the separate tasks you already have. This is a good start but the issue is that the robot sits at a desk and puts items on it while working. In order to get things right, you cannot just interleave instructions but also have to save and restore the items on the table.
这是可行的,但现在仅通过查看您创建的大任务描述就很难将单独的任务分开。此外,保存和恢复表上的项的过程很乏味,并且使任务描述更加混乱。
这就是线程抽象的用武之地。它让你假设你有无数个机器人,每个机器人都坐在不同的房间里自己的办公桌前。现在,您可以将任务描述扔到一个容器中,其他一切都由线程抽象的实现者处理。还记得吗?如果有足够多的工人,没有人需要一心多用。
通常,表明你的视角是有用的,说robot是指真正的机器人,说virtual robot是指线程抽象提供给你的机器人。
至此,在任务完全独立的情况下,多任务处理问题得到了解决。然而,让机器人走出自己的房间,相互交流,朝着一个共同的目标努力,这不是很好吗?你可能已经猜到了,这需要协调。红绿灯,排队,应有尽有。
作为一种中间总结,线程抽象解决了多任务处理的问题,并为合作创造了机会。没有它,我们只有一个机器人,所以合作是不可想象的。但是,它也给我们带来了协调(同步)的问题。现在我们知道了胎面抽象解决了什么问题,作为奖励,我们也知道了它带来了什么新挑战。
但是等等,为什么我们首先关心多任务处理呢?
首先,如果任务涉及等待,多任务处理可以提高性能。例如,当洗衣机在运转时,你可以很容易地开始准备晚餐。当你的晚餐在上面的时候,你可以把衣服晾出来。请注意,在这里等待是因为一个独立的组件为您完成了这项工作。涉及等待的任务称为I/O绑定任务。
其次,如果多任务处理是快速完成的,从鸟瞰的角度来看,它就表现为并行性。这有点像人眼将一系列静止图像感知为快速连续显示的运动图像。如果我给Alice写了一秒给Bob也写了一秒,你能分辨出这两封信是同时写的还是交替写的吗,如果你只看我每两秒在做什么?搜索多任务操作系统以获得更多信息。
现在,让我们关注如何实现线程抽象的问题。
从本质上讲,实现线程抽象就是编写一个任务,一个主任务,它负责调度所有其他任务。
要问的一个基本问题是:如果调度器调度所有任务,并且调度器也是一个任务,那么谁调度调度器?
Let's brake this down. Say you write a scheduler, compile it and load it into the main memory of a computer at the address 1024, which happens to be the address that is loaded into the processor's instruction pointer when the computer is started. Now, your scheduler goes ahead and finds some tasks sitting precompiled in the main memory. For example, a task starts at the address 1,048,576. The scheduler wants to execute this task so it loads the task's address (1,048,576) into the instruction pointer. Huh, that was quite an ill considered move because now the scheduler has no way to regain control from the task it has just started.
一种解决方案是在执行之前在任务描述中插入到调度程序(地址1024)的跳转指令。实际上,你不应该忘记保存机器人正在工作的桌子上的物品,所以在跳跃之前你还必须保存处理器的寄存器。这里的问题是,很难判断在哪里插入跳转指令。如果它们太多,就会产生过多的开销;如果它们太少,就会有一个任务独占处理器。
A second approach is to ask the task authors to designate a few places where control can be transferred back to the scheduler. Note that the authors don't have to write the logic for saving the registers and inserting the jump instruction because it suffices that they mark the appropriate places and the scheduler takes care of the rest. This looks like a good idea because task authors probably know that, for example, their task will wait for a while after loading and starting a washing machine, so they let the scheduler take control there.
上述两种方法都无法解决错误或恶意任务的问题,例如,它陷入无限循环,无法跳转到调度器所在的地址。
现在,如果你不能用软件解决问题该怎么办?在硬件上解决!所需要的是一个连接到处理器上的可编程电路,就像一个闹钟一样。调度器设置一个定时器和它的地址(1024),当定时器用完时,告警保存寄存器并将指令指针设置为调度器所在的地址。这种方法称为抢占式调度。
现在您可能已经开始意识到实现线程抽象与实现链表不同。线程抽象最著名的实现者是操作系统。它们提供的线程有时称为内核级线程。由于操作系统无法承受失去控制的后果,所有主要的通用操作系统都使用抢占式调度。
Arguably, operating systems feel like the right place to implement the thread abstraction because they control all the hardware components and can suspend and resume threads very wisely. If a thread requests the contents of a file stored on a hard drive from the operating system, it immediately knows that this operation will most likely take a while and can let another task occupy the processor in the meanwhile. Then, it can pause the current task and resume the one that made the request, once the file's contents are available.
然而,故事并未就此结束,因为线程也可以在用户空间中实现。这些实现者通常是编译器。有趣的是,据我所知,内核级线程是线程所能得到的最强大的线程。那么,我们为什么要用用户级线程呢?原因当然是性能。用户级线程更轻量级,因此您可以创建更多的线程,通常暂停和恢复线程的开销很小。
用户级线程可以使用async/await实现。您还记得实现控制返回调度程序的一个选项是让任务作者指定可以发生转换的位置吗?async和await关键字正是用于此目的。
现在,如果你已经做到了这一步,请做好准备,因为真正的乐趣即将到来!
您是否注意到我们几乎没有讨论并行性?我的意思是,我们不是使用线程并行地运行相关的计算,从而提高吞吐量吗?嗯,不安静..实际上,如果你只想要并行,你根本不需要这个机制。您只需创建与您拥有的处理单元数量一样多的任务,并且没有任何任务必须暂停或恢复。你甚至不需要调度程序,因为你不需要一心多用。
在某种意义上,并行是一个实现细节。如果您仔细想想,线程抽象的实现者可以在底层使用尽可能多的处理器。你可以从1950年开始编译一些编写良好的多线程代码,在今天的多核上运行,然后看到它利用了所有的核。重要的是,编写这段代码的程序员可能没有预料到这段代码会在多核上运行。
您甚至可以认为,当线程被用于实现并行性时,它们被滥用了:即使人们知道他们不需要多任务这一核心功能,但他们还是使用线程来获得并行性。
最后需要注意的是,用户级线程本身不能提供并行性。还记得开头的那句话吗?操作系统在虚拟计算机(进程)中运行程序,默认情况下通常配备单个虚拟处理器(线程)。无论您在用户空间中使用什么魔法,如果您的虚拟计算机只有一个虚拟处理器,那么您就不能并行运行代码。
我们想要什么?当然,我们需要并行性。但是我们也需要轻量级线程。因此,许多线程抽象的实现者开始使用一种混合方法:他们启动与硬件中处理单元数量一样多的内核级线程,并在几个内核级线程之上运行许多用户级线程。本质上,并行由内核级处理,多任务由用户级线程处理。
Now, an interesting design decision is what threading interface a language exposes. Go, for example, provides a single interface that allows users to create hybrid threads, so called goroutines. There is no way to ask for, say, just a single kernel-level thread in Go. Other languages have separate interfaces for different kinds of threads. In Rust, kernel-level threads live in the standard library, while user-level and hybrid threads can be found in external libraries like async-std and tokio. In Python, the asyncio package provides user-level threads while multithreading and multiprocessing provide kernel-level threads. Interestingly, the threads multithreading provides cannot run in parallel. On the other hand, the threads multiprocessing provides can run in parallel but, as the library's name suggests, each kernel-level thread lives in a different process (virtual machine). This makes multiprocessing unsuitable for certain tasks because transferring data between different virtual machines is often slow.
更多资源:
操作系统:原理与实践,作者:Thomas and Anderson
并发不是罗伯派克的并行
并行性和并发性需要不同的工具
Rust中的异步编程
Rust的异步转换内部
Rust的Async/Await之旅
你的功能是什么颜色?
为什么goroutine而不是线程?
为什么我的程序在更多cpu时运行得更快?
John Reese -用AsyncIO和Multiprocessing跳出GIL思考- PyCon 2018