Go 系统调用

操作系统中断的设计初衷是 CPU 响应硬件设备事件的一个机制。当某个输入输出设备发生了一件需要 CPU 来处理的事情,它就会触发一个中断;但是 CPU 也提供了指令允许软件触发一个中断,我们把它叫软中断。

大部分情况下,操作系统的能力通过软中断向我们写的软件开放,为此还专门引入了一个术语叫 “系统调用(syscall)”。

系统调用

在保护模式下,CPU 引入了 “保护环(Protection Rings)” 的概念。说白了,代码有执行权限等级的,如果权限不够,有一些 CPU 指令就不能执行。

从内存管理的角度,虚拟内存机制让软件运行在一个沙盒中,这个沙盒让软件感觉自己在独享系统的内存。但如果不对软件的执行权限进行约束,它就可以打破沙盒,了解到真实的世界。

我们通常说的操作系统是很泛的概念。完整的操作系统非常庞大。根据与应用的关系,我们可以把操作系统分为内核与外围。

所谓操作系统内核,其实就是指那些会向我们写的应用程序提供系统服务的子系统的集合,它们管理着计算机的所有硬件资源,也管理着所有运行中的应用软件(进程)。

操作系统内核的执行权限等级,和我们常规的软件进程不同。像 Intel CPU 通常把代码执行权限分为 Ring 0-3 四个等级。

操作系统内核通常运行在 Ring 0,而常规的软件进程运行在 Ring 3(当然近年来虚拟化机制流行,为了更好地提升虚拟化的效率,Intel CPU 又引入了 Ring -1 级别的指令,这些指令只允许虚拟机所在的宿主操作系统才能调用)。系统调用所基于的软中断,它很像一次间接的“函数调用”,但是又颇有不同。在实模式下,这种区别并不强烈。但是在保护模式下,这种差异会十分明显。

原因在于,我们的应用程序运行在 Ring 3(我们通常叫用户态),而操作系统内核运行在 Ring 0(我们通常叫内核态)。所以一次中断调用,不只是“函数调用”,更重要的是改变了执行权限,从用户态跃迁到了内核态。

从虚拟内存机制的视角,操作系统内核和所有进程都在同一个地址空间,也就是,操作系统内核,它是所有进程共享的内存。

操作系统内核的代码和数据,不只为所有进程所共享,而且在所有进程中拥有相同的地址。从单个进程的视角,中断向量表的地址,以及操作系统内核的地址空间是一个契约。有了中断向量表的地址约定,用户态函数就可以发起一次系统调用(软中断)。

由于虚拟内存中的内存页保护机制,用户不能跳过中断调用内核函数,内存页可以设置 “可读、可写、可执行” 三个标记位。操作系统内核虽然和用户进程同属一个地址空间,但是被设置为“不可读、不可写、不可执行”。虽然这段地址空间是有内容的,但是对于用户来说是个黑洞。

编程接口

标准库的能力,大部分与操作系统能力相关,但或多或少进行了适度的包装。例如,HTTP 是应用层协议,和操作系统内核关联性并不大,基于 TCP 的编程接口可以自己实现,但由于 HTTP 协议细节非常多,这个网络协议又是互联网世界最为广泛应用的应用层协议,故此 Go 语言提供了对应的标准库。

动态库

从操作系统的角度来说,它仅仅提供最原始的系统调用是不够的,有很多业务逻辑的封装,在用户态来做更合适。

几乎所有主流操作系统都有自己的动态库设计,包括:

  • Windows 的 dll(Dynamic Link Library);

  • Linux/Android 的 so(shared object);

  • Mac/iOS 的 dylib(Mach-O Dynamic Library)。

动态库本质上是实现了一个语言无关的代码复用机制。它是二进制级别的复用,而不是代码级别的。这很有用,大大降低了编程语言标准库的工作量。

动态库的原理其实很简单,核心考虑两个东西。

  • 浮动地址。动态库本质上是在一个进程地址空间中动态加载程序片段,这个程序片段的地址显然在编译阶段是没法确定的,需要在加载动态库的过程把浮动地址固定下来。这块的技术非常成熟,我们在实模式下加载进程就已经在使用这样的技术了。

  • 导出函数表。动态库需要记录有哪些函数被导出(export),这样用户就可以通过函数的名字来取得对应的函数地址。

动态库,编程语言的设计者实现其标准库来说就多了一个选择:直接调用动态库的函数并进行适度的语义包装。大部分语言会选择这条路,而不是直接用系统调用。

计算机基础架构:

中央处理器(CPU)、编程语言、操作系统这三者对应用软件开放的编程接口。

进程与线程

进程是操作系统从安全角度来说的隔离单位,不同进程之间基于最低授权的原则。在创建一个进程这个事情上,UNIX 偷了一次懒,用的是 fork(分叉)语义。所谓 fork,就是先 clone 然后再分支,父子进程各干各的。

这样创建进程很讨巧,不用传递一堆的参数,使用上非常便利。

线程的出现,则是因为操作系统发现同一个软件内还是会有多任务的需求,这些任务处在相同的地址空间,彼此之间相互可以信任。

协程与 goroutine

协程并不是操作系统内核提供的,它有时候也被称为用户态线程。这是因为协程是在用户态下实现的。

对于一个网络服务器,我们可以用下面这个简单的模型看它:

对网络服务器来说,大量的来自客户端的请求包和服务器的返回包,都是网络 IO;在响应请求的过程中,往往需要访问存储来保存和读取自身的状态,这也涉及本地或网络 IO。

如果这个网络服务器有很多客户,那么整个服务器就充斥着大量并行的 IO 请求。操作系统提供的标准网络 IO 有以下这些成本:

  • 系统调用机制产生的开销;

  • 数据多次拷贝的开销(数据总是先写到操作系统缓存再到用户传入的内存);

  • 因为没有数据而阻塞,产生调度重新获得执行权,产生的时间成本;

  • 线程的空间成本和时间成本(标准 IO 请求都是同步调用,要想 IO 请求并行只能使用更多线程)。

在一些人心目中会有一个误区:操作系统的系统调用很慢。这句话很容易被错误地理解为系统调用机制产生的开销很大。

系统调用虽然比函数调用多做了一点点事情,比如查询了中断向量表(这类似编程语言中的虚函数),比如改变 CPU 的执行权限(从用户态跃迁到内核态再回到用户态)。但是注意这里并没有发生过调度行为,所以归根结底还是一次函数调用的成本。怎么理解操作系统内核我们示意如下:

从操作系统内核的主线程来说,内核是独立进程,但是从系统调用的角度来说,操作系统内核更像是一个多线程的程序,每个系统调用是来自某个线程的函数调用。

为了改进网络服务器的吞吐能力,现在主流的做法是用 epoll(Linux)或 IOCP(Windows)机制,这两个机制颇为类似,都是在需要 IO 时登记一个 IO 请求,然后统一在某个线程查询谁的 IO 先完成了,谁先完成了就让谁处理。

从系统调用次数的角度,epoll 或 IOCP 都是产生了更多次数的系统调用。从内存拷贝来说也没有减少。所以真正最有意义的事情是:减少了线程的数量。

既然不希望用太多的线程,网络服务器就不能用标准的同步 IO(read/write)来写程序。

但是异步 IO 编程让程序逻辑因为 IO 异步回调函数而碎片化。

多线程的时间成本开销:

  • 执行体切换本身的开销,它主要是寄存器保存和恢复的成本,可腾挪的余地非常有限;

  • 执行体的调度开销,它主要是如何在大量已准备好的执行体中选出谁获得执行权;

  • 执行体之间的同步与互斥成本。

线程的空间成本。它可以拆解为:

  • 执行体的执行状态;

  • TLS(线程局部存储);

  • 执行体的堆栈。

空间成本是第一根稻草。默认情况下 Linux 线程在数 MB 左右,其中最大的成本是堆栈(虽然,线程的堆栈大小是可以设置的,但是出于线程执行安全性的考虑,线程的堆栈不能太小)。如果一个线程 1MB,那么有 1000 个线程就已经到 GB 级别了,消耗太快。

执行体的调度开销,以及执行体之间的同步与互斥成本,也是一个不可忽略的成本。虽然单位成本看起来还好,但是盖不住次数实在太多。

系统中有大量的 IO 请求,大部分的 IO 请求并未命中而发生调度。另外,网络服务器的存储是个共享状态,也必然伴随着大量的同步与互斥操作。

协程就是为了这样两个目的而来:

  • 回归到同步 IO 的编程模式;

  • 降低执行体的空间成本和时间成本。

大部分协程库都缺了协程的调度;协程的同步、互斥与通讯;协程的系统调用包装,尤其是网络 IO 请求的包装等功能,协程的堆栈如果太小则可能不够用;而如果太大则协程的空间成本过高,影响能够处理的网络请求的并发数。理想情况下,堆栈大小需要能够自动适应需要。一个完备的协程库你可以把它理解为用户态的操作系统,而协程就是用户态操作系统里面的 “进程”。

Go 语言里面的用户态 “进程” 叫 goroutine。

它有这样一些重要设计:

  • 堆栈开始很小(只有 4K),但可按需自动增长;

  • 坚决干掉了 “线程局部存储(TLS)” 特性的支持,让执行体更加精简;

  • 提供了同步、互斥和其他常规执行体间的通讯手段,包括大家非常喜欢的 channel;

  • 提供了几乎所有重要的系统调用(尤其是 IO 请求)的包装。

进程内的执行体有两类:用户态的协程(以 Go 语言的 goroutine 为代表)、操作系统的线程,我们对这两类执行体的协同机制做个概要。如下:

原子操作

原子操作是 CPU 提供的能力,与操作系统无关。这里列上只是为了让你能够看到进程内通讯的全貌。

顾名思义,原子操作的每一个操作都是原子的,不会中途被人打断,这个原子性是 CPU 保证的,与执行体的种类无关,无论 goroutine 还是操作系统线程都适用。从语义上来说,原子操作可以用互斥体来实现,只不过原子操作要快得多。

执行体的互斥

互斥体也叫锁。锁用于多个执行体之间的互斥访问,避免多个执行体同时操作一组数据产生竞争。其使用界面上大概是这样的:

func (m *Mutex) Lock()
func (m *Mutex) Unlock()

锁的使用范式比较简单:在操作需要互斥的数据前,先调用 Lock,操作完成后就调用 Unlock。但总是存在一些不求甚解的人,对锁存在各种误解。

有的人会说锁很慢。甚至我曾看到有 Go 程序员用 channel 来模拟锁,理由就是锁太慢了,尽量不要用锁。产生“锁慢,channel 快”这种错觉的一个原因,可能是人们经常看到这样的忠告:

不要通过共享内存(锁)来通信,要通过通信(channel)来共享内存。

不明就里的人们看到这话后,可能就有了这样的印象:锁是坏的,锁是性能杀手,channel 是好的,是 Go 发明的先进武器,应该尽可能用 channel,而不要用锁。

快慢是相对而言的。锁的确会导致代码串行执行,所以在某段代码并发度非常高的情况下,串行执行的确会导致性能的显著降低。但平心而论,相比其他的进程内通讯的原语来说,锁并不慢。从进程内通讯来说,比锁快的东西,只有原子操作。

例如 channel,作为进程内执行体间传递数据的设施来说,它本身是共享变量,所以 channel 的每个操作必然是有锁的。事实上,channel 的每个操作都比较耗时。锁不容易控制的另一个表现是锁粒度的问题。

有一句箴言要牢记:不要在锁里面执行费时操作。这里 “锁里面” 是指在mutex.Lock和mutex.Unlock之间的代码。

因为从需求上来说,如果当前我们正在执行某个读操作,那么再来一个新的读操作,是不应该挡在外面的,大家都不修改数据,可以安全地并发执行。但如果来的是写操作,就应该挡在外面,等待读操作执行完。整体来说,

读写锁的特性就是:

读操作不阻止读操作,阻止写操作;写操作阻止一切,不管读操作还是写操作。

协同机制

机制大体可分为:互斥、同步、资源共享以及通讯等原语。对于这些协同机制,对比了 Linux、Windows、iOS 这三大操作系统的支持情况,整理内容如下:

启动进程

一个进程启动另一个子进程通常有两种方法:

  • 创建子进程;

  • 让 Shell 配合执行某个动作。

iOS 很有意思,它并不支持创建子进程。在进程启动这件事情上,它做了两个很重要的变化:

  • 软件不再创建多个进程实例,永远是单例的;

  • 一个进程要调用另一个进程的能力,不是去创建它,而是基于 URL Scheme 去打开它。

在 iOS 下,一个软件可以声明自己实现了某种 URL Scheme,比如微信可能注册了“weixin”这个 URL Scheme,那么调用

UIApplication.openURL("weixin://...")

都会跳转到微信。通过这个机制,我们实现了支付宝和微信支付能力的对接。URL Scheme 机制并不是 iOS 的发明,它应该是浏览器出现后形成的一种扩展机制。Windows 和 Linux 的桌面也支持类似的能力,在 Windows 下调用的是 ShellExecute 函数。

信号量

信号量(Semaphore)概念是 Dijkstra(学过数据结构可能会立刻回忆起图的最短路径算法,对的,就是他发明的)提出来的。信号量本身是一个整型数值,代表着某种共享资源的数量(简记为 S)。

信号量的操作界面为 PV 操作。

P 操作意味着请求或等待资源。执行 P 操作 P(S) 时,S 的值减 1,如果 S < 0,说明没有资源可用,等待其他执行体释放资源。

V 操作意味着释放资源并唤醒执行体。执行 V 操作 V(S) 时,S 的值加 1,如果 S <= 0,则意味着有其他执行体在等待中,唤醒其中的一个。

看到这里,你可能敏锐地意识到,条件变量的设计灵感实际上是从信号量的 PV 操作进一步抽象而来,只不过信号量中的变量是确定的,条件也是确定的。进程间的同步与互斥原语并没有进程内那么丰富(比如没有 WaitGroup,也没有 Cond),甚至没那么牢靠。

因为进程可能会异常挂掉,这会导致同步和互斥的状态发生异常。比如,进程获得了锁,但是在做任务的时候异常挂掉,这会导致锁没有得到正常的释放,那么另一个等待该锁的进程可能就会永远饥饿。

最后更新于