blog/source/_posts/threads-and-processes.md
2022-04-04 23:07:49 +08:00

15 KiB
Raw Permalink Blame History

title date tags
进程、线程、协程 2022-03-31 08:07:45
高性能I/O

程序运行时最重要的便是Program CounterPC和Stack。Program Counter程序计数器记录程序运行的位置Stack保存当前的数据。

+-------+ |Stack Address
|  v1   | |
+-------+ |
|  v2   | |
+-------+ |
|  v3   | |
+-------+ |
|  v4   | |
+-------+ v <-- top
Stack 示意图

这是一个Thread线程。本文主要是为了说明人们是怎么把这么简单的东西玩出各种花样的完完全全是一篇走马观花的介绍。本文会解释关于线程的一些概念并展示一些新的有意思的东西。

系统线程和用户空间线程

操作系统管理硬件向应用提供简单的API。大部分操作系统都包含线程管理。通常一个系统进程包含一个或多个系统线程OS Threads这些线程共享进程资源——内存、file descriptors被隔离在同一个环境中。Linux Kernel也是这么做的比如说可以用于限制资源访问的cgroups以进程为单位管理资源。操作系统内核通常在内核里对系统线程进行排程scheduling。内核会跟踪线程的状态通过算法确定下一个运行的线程。进行这个操作的的部分叫做排程器scheduler

用户空间userspace是指虚拟内存virtual memory里内核空间以外的空间现在也用来表示内核以外跟内核交互的代码userland在大部分情况下userspace和userland这两个词是混用的。用户空间线程也需要排程有时也有排程器但是它们都实现在用户空间里。在用户空间里实现可以免去切换到特权模式supervisor mode或内核模式kernel mode时切换上下文context switching的损耗。

Linux切换系统线程的流程示意
Thread0 -> [保存Thread0的上下文PC和Stack] -> [恢复内核的上下文] -> Linux Kernel -> [排程] -> [保存内核的上下文] -> [恢复Thread1的上下文PC和Stack] -> Thread1
                                               ^进入特权模式                                                               ^离开特权模式

用户空间线程的通常切换流程:
Thread0 -> [排程] -> Thread1

系统线程没有那么沉重

通常使用用户空间线程的理由是“系统线程很重”需要的内存更多、切换速度更慢……但至少在Linux上系统线程没有那么“重”。

首先创建系统线程的栈空间在实际使用前并不占用内存空间。这是因为Linux默认启用过度提交Overcommit在虚拟内存中申请的内存并不会在实际内存中预留。你可以创建上千个2MB栈的线程但是每个线程实际只占用8KB。

系统线程切换速度慢的问题并不在于我们通常认为的上下文切换它虽然仍然消耗时间但没有我们想像的慢在Google工程师的测试中切换来回只要<50ns。消耗时间更多的是排程算法排程算法是计算密集的工作占用的时间比上下文切换多。

解决方法是使用计算简单甚至不需要计算的算法这类算法经常是非公平算法。Google的工程师设计了一组叫做SwitchTo的系统调用可以让应用告诉系统接下来切换到指定线程。这组系统调用将线程之间上下文切换的性能提升了三十倍。尚未合并到上游

User threads...with Threads slides 下载

抢占式线程和协同式线程

我们知道我们不可能在寥寥几个CPU核之上同时运行数量多于其数量的线程我们需要一些算法决定

  • 线程何时运行
  • 线程能运行多久(何时结束)

抢占式和协同式是两个类型,描述了算法解决后者时选择的方向。抢占式算法有可能强制暂停线程,协同式算法只有线程显式或隐式让出时才暂停线程。

Linux默认情况下使用抢占式算法内核在每次线程运行时都会指定时间片线程让出或时间片到期时内核会取回控制权重新排程。抢占式线程很难在用户空间中实现但并非不可能。不过抢占式线程不符合用户空间线程的普遍目的所以用户空间线程一般是协同式线程。

抢占式算法保证了公平性但对性能有负面影响协同式线程保证了本地性性能更好。不是所有系统默认提供的都是抢占式线程比如FreeRTOS这类面向实时应用的操作系统提供甚至默认提供协同式线程。

无栈线程Stackless Threads

“无栈”的意思不是“没有栈”而是“不使用栈”。其状态的大小已经确定可以直接放在栈上而不需要使用栈。Rust和Zig的异步函数、async.h、protothread就属于这种类型。

前面的Rust和Zig通过编译器将代码翻译成状态机后两者使用宏实现状态机并且要求用户用一个固定的数据结构在让出之间保存状态。需要注意的是状态可以直接放在栈上不意味着其运行过程不使用栈只代表它可以不需要一个单独的栈。

在线程中同步

无论你使用的是抢占式线程还是协同式线程,你都有可能需要在线程中进行同步。当然,协同式多线程在一些状况下不需要同步。线程安全是说在多线程环境下能够正常工作。

哪怕只是简单的加法只要它涉及到多线程并且不是原子操作你都应该仔细考虑它的副作用。在很多在指令集上加法包含取值、加法、保存等多个操作参照下列LLVM IR

;;void spam() {
;;    int b = 6;
;;    int c = 4;
;;    int a = b + c;
;;}
define dso_local i32 @spam() #0{
  %2 = alloca i32, align 4
  %3 = alloca i32, align 4
  %4 = alloca i32, align 4
  store i32 6, i32* %2, align 4
  store i32 4, i32* %3, align 4
  %5 = load i32, i32* %2, align 4 ;; * a = b + c
  %6 = load i32, i32* %3, align 4 ;; |
  %7 = add nsw i32 %5, %6         ;; |
  store i32 %7, i32* %4, align 4  ;; *
  %8 = load i32, i32* %1, align 4
  ret void
}

非原子操作可以帮助CPU进行指令级并行intrustion-level paralism同时执行几条不相干的指令。这可以显著提高流水线性能。但是非原子操作在多线程同时访问一个值的情况下可能导致奇怪的行为。

考虑线程th0和th1th0获取b=2时th1将b修改为b=4th0获取c=3时th1将c修改为c=5这时th0拿到的是b=2和c=3a=b+c=5而th1会认为a=b+c=4+5=9。你可以使用原子操作指令进行原子操作。另外在抢占式线程的情况下CPU的控制权随时都有可能被取回你应该按照“在任何指令执行后线程就会被挂起”考虑你的代码。

现在还有一个比较重要的优化叫做非序执行Out-of-order execution也可以叫做代码重排code reorder就是当你的代码满足一定条件时编译器或者CPU会将你的代码重新排列以满足优化要求。但是这不一定是你需要的它会把你的代码打乱影响到你代码的副作用。你可以使用内存围栏memory barrier要求特定的顺序。

任何同步最后都有可能成为性能瓶颈,优化你的代码架构可以帮助减少同步技术的使用范围。

同步的基本技术Lock和Condition

推荐阅读Locking in WebKit

其它技术

  • 事务性内存Transactional Memory
  • 信号量Semaphore、读写锁Read-write Lock
  • Compare-And-SwapCAS、原子操作指令

无锁Lock-less、无死锁Deadlock-free和无等待Wait-less数据结构

通常,无锁数据结构在频繁操作时性能表现比使用锁的数据结构更好,常见的无锁数据结构有:

  • Lock-less Ring Buffer
  • 无锁队列

无锁的意思并非是“无等待”无锁结构的内部经常使用某种形式的自旋锁来重复执行操作直到成功。但是这个锁的影响范围比单独的锁要小得多对整体性能的影响更小。无锁数据结构的操作本身一般是非阻塞non-blocking无等待的通过类似自旋锁的操作可以确保操作成功但是会造成阻塞。

使用自旋锁的实现在大量参与者同时操作同时阻塞时会影响性能,虽然通常需要非常非常多的参与者才会影响性能:自旋锁会让这些线程保持活跃。使用混合线程(稍后在“事件驱动编程和协同式多线程”中讨论)时会使相应的协同式线程无法从活跃线程中离开,在某些情况下会造成问题。

无死锁数据结构保证操作数据结构的线程不会死锁。最典型的是双锁队列Two-Lock Queue一个头锁一个尾锁修改相应部分时就持有相应的锁。

事件驱动编程和协同式多线程

阻塞线程等待I/O操作完成从并发角度而言并不是什么好主意I/O操作通常需要花费一些时间来完成。幸运的是Linux内核内部的I/O操作其实都是异步的线程阻塞会被看作是一次隐式让出给其它线程一个运行的机会。但是这个机会为什么不给我们自己的代码呢我们只需要在完成或者错误的时候调用一下回调函数就好了这样剩下的时间我们可以运行别的代码。

-- 随便乱写的伪代码
local uv = require "uv"

local file = uv.open_file("./echo.txt", "a+")

local run = true
while run do -- 这写法其实不对,千万别学,只是为了展示一下回调地狱
  file:read(256, function(fail, result)
    if not fail then
      file:write(result, function(fail)
        if fail then
          print("fail:"..fail)
          run = false
        end
      end)
    else
      run = false
    end
  end) -- file:read file:write 都是非阻塞的函数,可以想象内存很快就爆炸了
  print("Going to echo 256 bytes") -- 你的stdout将会塞满这玩意因为读和写没完成就可以来到这行了
end

真是糟糕的味道。所幸我们后来使用了一个叫做Promise或者Future的东西它代表一个在未来完成的操作。

-- 仍然是乱写的伪代码

local uv = require "uv"

local file = uv.open_file("./echo.txt", "a+")

local run = true
while run do
  file:read(256)
  :on_ok(function(result)
    return file:write(result)
  end)
  :on_err(function(err)
    print(err)
    run = false
  end)
  print("Going to echo 256 bytes")
end

好吧,干净了点,但是现在我们还可以弄得更干净。

-- 也是乱写的伪代码不过确实可以在Lua里实现

local uv = require "uv"

local file = uv.open_file("./echo.txt", "a+")

while true do -- 这次逻辑上是没错的
  local status, blk = pawait(file:read(256))
  if not status then
    break
  end
  local status, err = pawait(file:write(blk))
  if not status then
    print("fail:"..err)
    break
  end
  print("Going to echo 256 bytes") -- 它不会塞满你的stdout了因为它在上面两个操作确实完成的时候才输出
end

发现了吗?最后一个版本几乎和同步代码一模一样:

-- 也是乱写的伪代码不过确实可以在Lua里实现

local file = io.open("./echo.txt", "a+")

while true do -- 这次逻辑上是没错的
  local status, blk = file:read(256)
  if not status then
    break
  end
  local status, err = file:write(blk)
  if not status then
    print("fail:"..err)
    break
  end
  print("Going to echo 256 bytes") -- 它不会塞满你的stdout了因为它在上面两个操作确实完成的时候才输出
end

但是问题在于:它在什么地方“运行别的代码”呢?答案就是里面的pawait(xxx)。把整段代码看作一个协同式线程,这个线程将在pawait的时候让出,在里面的操作xxx完成之后返回值、继续运行这个线程。在线程让出的时候就可以运行别的线程。

进行I/O的过程可以被分为两个事件请求I/O操作、I/O操作完成。但是事件驱动的代码并不不好写事件带有上下文显式处理上下文会很麻烦。通过线程我们可以在保存上下文的同时利用这段空白时间执行别的代码。要达到这个目的只需要协同式线程尽管使用线程会对性能带来一些负面影响但是我相信你并不想用那么多的回调或者Promise。

Thread0: [I/O请求] ---------阻塞-----------------> [I/O响应]
                   v 排程                                  ^ 排程
Thread1:            [I/O请求] --------阻塞------------------> [I/O响应]
                                   v 排程                             ^ 排程
Thread2:                            [I/O请求] --------阻塞------------------> [I/O响应]

这里的线程经常使用用户空间线程。由于一些限制很多实现只在一个系统线程中运行所有的用户空间线程在应对I/O密集的的环境时不会有很大影响。但我们总不希望“一核有难N核围观”……

混合线程

我们可以在多个系统线程中运行用户空间线程。GoRust的TokioKotlin的Coroutine就采取了这种方法。这种方法让用户空间的协同式线程可以并行执行更快地处理I/O密集之余的计算部分。

简单地说这些实现会维护一个线程池——线程的数量通常根据CPU的核心数确定——来运行用户空间线程。但是需要注意虽然现在用户空间线程可以并行运行但它们还是协同式线程只有在显式或隐式让出时才挂起。如果你有一个用户空间线程一直活跃它不会挂起并且一直占用你线程池的一个线程。许多实现提供了手动让出的方法你可以使用这些方法显式让出。

特别值得注意的是自旋锁——自旋锁不会让你的线程休息,你必须要确保自旋锁不会长时间卡在那。但是你可以用别的锁,而且这些实现一般都会提供合适的锁,开销会比自旋锁略微大一些。

因为现在你的线程可以并行运行了你还可以考虑更多地使用基于消息传递的并发模型比如说Actor模型

{% wikipedia title:Actor_model wikiButton:true %}

扩展阅读