并发编程的核心抽象是实现一个计算图,计算发生在节点上,边表示节点之间的依赖关系,同时计算图在运行时可能是动态变化的
使用条件变量、锁、信号量等 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()
),那么当前所在的整个操作系统线程都会被卡住,这意味着所有在该线程上运行的协程都将停止执行,从而失去了并行
示例
|
|
这个例子很好地阐释了协程的特点:
- 在同一个操作系统线程中执行:虽然创建了非常多个
T_worker
对象,但它们都运行在程序的同一个操作系统线程中,开销远小于创建相同数量个操作系统线程 - 由程序控制调度:
while True: random.choice(threads).send(None)
这段代码是自定义的简单调度器,它随机选择一个协程并激活它,使其执行直到遇到yield()
暂停,然后再选择下一个协程 - 资源占用极低:由于不涉及操作系统层面的上下文切换,除了协程本身所需要的一小块内存用于保存其栈帧和状态外,协程不占用额外的操作系统资源,因此可以创建海量的协程实例,非常适合高并发的 I/O 密集型任务。