并发编程的奇技淫巧:Go语言调度器的内在工作机制

超级欧派课程 2024-03-27 10:50:27

当我们谈论高性能的服务器,实现高并发处理无疑是评估的一个关键因素。在单线程之上,我们居然可以构建出能够同时应付数百万客户端的高效服务器,这实在是神奇。本文将带你深入并发编程的奥秘,一探Go语言调度器(Scheduler)的内在工作机制。

为何不为每个客户端创建一个线程?

一开始,人们可能会想到为每个客户端都创建一个线程来实现并发,但这种方式有几个明显的问题。首先,操作系统为每个新线程分配栈空间是有开销的,虽然并不是一开始就立即占据了所有的RAM,但这意味着内存的分配粒度至少与页面大小相等。其次,我们还需要修改相关的系统限制设定,例如Linux上的线程最大数量threads-max或进程ID的最大值pid_max​。还有,频繁的上下文切换会消耗大量的CPU周期,且线程栈的大小是静态的,一旦分配后无法增长或者缩减。

异步I/O是如何实现的?

当执行像read​、write​或accept​这样可能会阻塞线程的操作时,我们为什么要让整个线程停止运行呢?这实在是浪费了CPU资源。Linux提供了将socket标记为非阻塞的方式,例如使用ioctl(fd, FIONBIO)​或fcntl & O_NONBLOCK​。当你向这样的socket发起read​请求时,若没有数据到来,它会立即返回一个错误,其errno​值为EWOULDBLOCK​。

利用非阻塞sockets,我们可以改造echod​服务器,让其在单线程中支持多个并发客户端。这样我们就可以在没有任务可做时让CPU休息,比如没有客户端请求连接,也没有数据包发送到客户端(可能是客户端忙于其他事务)。

什么是事件循环(Event Loop)?

事件循环(Event Loop)本质上是这样一个结构:

事件驱动的编程模型在使用事件循环时天然而然地是事件驱动的,每个注册的事件都有一个一旦准备就绪就会执行的回调函数。

如何实现抢占(Preemption)?

当我们在只有一个CPU核心的计算机上运行多个线程时,你是否想过是如何实现的?其实是通过抢占机制来实现的。大多数现代操作系统使用定时中断。CPU在时间经过一定量后接收一个中断。中断会停止当前运行的任何内容,并且中断处理程序会调用调度器来决定是否进行上下文切换。

用户模式的应用程序无法注册中断,那我们如果想在用户模式实现一个抢占式调度器,应当怎么做呢?一个简单的解决方案是利用内核的抢占式调度器。创建一个线程,定期向运行我们的调度器的线程发送信号。

Go语言在1.14版本中通过从监控线程(runtime.sysmon)定期发送信号到运行goroutine的调度器线程,实现了它们的调度器抢占性。

栈满(Stackful) vs 栈空(Stackless)

到目前为止,我一直用“任务”这个词来避免混淆,但它们有很多不同的名字,比如纤程(fibers)、greenlets、用户模式线程、绿色线程、虚拟线程、协程和goroutine。

协程简单来说就是一个可以暂停和恢复的程序。实现协程主要有两种方式:为每个协程分配一个栈(栈满)或者让标记为async​的每个函数返回一个可以保持所有需要暂停和恢复该函数的状态的对象(栈空)。

栈满和栈空极大地影响了API,每种方式都有其自身的优势和劣势。即使Go语言需要定期进行上下文切换,也需要定期检查是否有足够的栈空间来继续运行;如果不够,它会重新分配栈以获得更多内存,将原来的内容复制过去,并修正所有指向旧栈的指针指向新栈。Rust的async​将代码块转换为一个不运行的状态机,直到你await​它。这种做法在运行时非常轻量级,内存正好分配所需,这非常适合Rust的嵌入式使用场景。但这种方法主要问题是“函数染色”(function coloring),即一个async​函数只能在另一个async​函数内部调用。

调度器算法

调度器还负责决定在一个任务完成后应运行哪个任务。

Linux的SCHED_FIFO​调度器就是一种最简单的方法,即按照任务加入任务队列的顺序来运行任务。

在多核CPU上,最简单的多核调度方式就是和之前一样执行。拥有一个全局队列,存放准备好的任务,并在核心准备好时运行它们。

更多

掌握Golang精髓,释放编程潜能!关注我的《Golang实用技巧》专栏,它将为你揭秘生产环境最佳实践,带你探索高并发编程的实用教程。从分享实用的Golang小技巧到深入剖析实际应用场景,让你成为真正的Golang大师。无论你是初学者还是经验丰富的开发者,这里都有你所需要的灵感和知识。让我们一同探索Golang的无限可能!

0 阅读:2

超级欧派课程

简介:感谢大家的关注