并发编程的核心抽象是实现一个计算图,计算发生在节点上,边表示节点之间的依赖关系,同时计算图在运行时可能是动态变化的

使用条件变量、锁、信号量等 api 去实现计算图并不是一个优雅的实现方式,因为这样会在代码中引入众多干扰代码,也可能导致一些问题

为此可以增加一些功能受限的语法,可以在同样描述计算图的功能下减少了许多潜在的问题

高性能计算中的并行编程

在高性能计算中,计算图通常易于静态切分,尤其适用于物理模拟的网格划分,为此 HPC 发展出多种高效的并行编程模型,具体学习可以参考 SJTU HPC 学习手册

MPI: 分布式内存并行

每个 MPI 进程有独立的内存空间,进程间通过显式消息传递(发送/接收)交换数据

OpenMP: 共享内存并行

多个线程在同一地址空间中并行执行,所有线程可以直接访问相同的数据,使用 #pragma omp 指令实现并行化

对非计算机专业来说非常友好,只需要在正常的代码上面加上编译指令即可,能轻松实现高效的并行优化

CUDA: GPU 异构并行

CPU 调度,GPU 执行大规模并行计算

概念

  • 核函数 (Kernel) :在 GPU 上并行执行的函数
  • 线程层次:线程 (threadIdx) 组成线程块 (blockIdx),线程块组成网格 (gridDim)
  • 内存层次:寄存器、共享内存(块内高速)、全局内存(所有线程可访问)

我们身边的并发编程

从 Web 1.0 到 Web 2.0

在 Web 时代用的最广泛的是 Javascript

  • Asynchronous JavaScript and XML (Ajax; ~1999)
    • 允许网页实现“后台刷新”
  • jQuery $ (2006): A DOM Query Language (编程抽象)

Web 2.0 时代的并发编程

线程开销大,并且大多数 Web 开发者难以进行并发编程

从而有了event-based concurrency (动态计算图) 的机制:禁止任何计算节点并行(和高性能计算的选择不同)

  • 允许网络请求、sleep 在后台进行(这才是主要开销),执行完之后产生新的计算节点
  • 事件可以在浏览器里看到

直接用这样的方式描述计算图会产生大量屎山代码 (Callback hell) ,现代的选择是动态描述计算图

数据中心的并发编程

数据中心以海量分布式数据为中心,需要处理的需求有:

  • 实时的“小数据”处理:内容分发、弹幕……
  • 离线的“大数据”处理:内容索引、数据挖掘……
  • 为 AI 提供支持

数据中心里的并发编程需要满足高吞吐量低延迟的特点,难点在于:

  • 处理事件可能需要读写持久存储或请求网络上的服务,从而导致延迟不确定
  • 线程维护和上下文切换都会带来开销

在数据中心进行并发编程时,传统的通过手动管理线程、锁等低级并发原语的方式变得越来越复杂且难以扩展,因此今天的解决方案是无服务器计算 (Serverless Computing) ,特别是函数即服务 (Function as a Service, FaaS) ,其优势在于

  • 开发人员不再需要直接处理底层的并发问题
  • FaaS 函数通常是短暂的、无状态的,并且每个请求通常触发一个独立的函数实例,不同的函数实例之间通常不共享内存,这从根本上规避了传统多线程编程中由于共享内存问题

这样的范式使得现代存储系统实现高可靠、低延迟的多副本分布式存储和计算变得非常 Challenge ,数据一致性、服务时刻可用和容忍机器离线三个特性不可以兼得

协程

协程是一种程序组件,与线程在概念上有一些相似之处,比如都在进程内部拥有独立的栈帧,并能在运行时维护自己的状态,可以看作是一个共享内存的状态机

然而,协程与线程最大的不同在于它们的调度方式上下文切换开销

  • 协作式调度:协程的执行是协作式的,会“一直执行” ,直到遇到 yield() 等指令时,才会主动放弃处理器控制权,将执行权交还给调用者或调度器,这与线程的抢占式调度(由操作系统决定何时中断和切换)不同

  • 操作系统“不感知”的上下文切换:当协程通过 yield() 进行切换时,这仅仅是一个函数调用级别的操作,不需要像线程切换那样,保存和恢复完整的上下文信息,因此这种切换开销非常小,并且是操作系统完全不感知

  • 阻塞 I/O 的局限性:由于协程是协作式执行的,如果在一个协程内部执行了阻塞性操作(如耗时的 I/O 操作或 sleep()),那么当前所在的整个操作系统线程都会被卡住,这意味着所有在该线程上运行的协程都将停止执行,从而失去了并行

示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import random

def T_worker(name):
    i = 0
    while (i := i + 1):
        print(f'[{name}] i = {i}')
        yield()

threads = [T_worker(i) for i in range(1000000)]
while True:
    random.choice(threads).send(None)

这个例子很好地阐释了协程的特点:

  • 在同一个操作系统线程中执行:虽然创建了非常多个 T_worker 对象,但它们都运行在程序的同一个操作系统线程中,开销远小于创建相同数量个操作系统线程
  • 由程序控制调度while True: random.choice(threads).send(None) 这段代码是自定义的简单调度器,它随机选择一个协程并激活它,使其执行直到遇到 yield() 暂停,然后再选择下一个协程
  • 资源占用极低:由于不涉及操作系统层面的上下文切换,除了协程本身所需要的一小块内存用于保存其栈帧和状态外,协程不占用额外的操作系统资源,因此可以创建海量的协程实例,非常适合高并发的 I/O 密集型任务。