3. 多核编程模型:共享内存模型 vs 消息传递模型,数据并行与任务并行

聊到多核编程,绕不开两个核心模型:共享内存和消息传递。说白了,它们就是多核之间「怎么聊天、怎么干活」的两种基本方式。

我个人习惯把共享内存模型想象成「合租一套房」——大家共用客厅、厨房,谁都能拿冰箱里的东西。而消息传递模型更像是「各自住单间」,想借个酱油得敲门递纸条。

嗯,咱们先看看这两种模型到底怎么玩。

3.1 共享内存模型

共享内存模型,就是多个线程或进程直接读写同一块内存区域。听起来简单,对吧?但坑也在这里。

我在项目中遇到过一个问题:两个线程同时更新一个计数器,结果跑出来的值永远比预期少。为什么?因为 count++ 不是原子操作——它分三步:读、改、写。两个线程同时读,都读到 100,各自加 1 再写回去,结果还是 101,而不是 102。

核心要点:共享内存必须配合同步机制(锁、原子操作、屏障)来保证数据一致性。

Python 里用 threading 模块就能体验共享内存模型。看个例子:

import threading

counter = 0
lock = threading.Lock()

def increment():
    global counter
    for _ in range(100000):
        with lock:          # 加锁保护共享资源
            counter += 1

threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()

print(counter)  # 输出 400000,正确!

你想想看,如果不加锁,结果大概率不是 400000。这就是共享内存模型的典型场景——数据共享方便,但同步开销大。

我的建议:能用原子操作就别用锁。Python 的 queue.Queue 内部已经做了线程安全,优先用它。

3.2 消息传递模型

消息传递模型,每个进程/线程有自己的私有内存,不共享数据。它们通过发送和接收消息来通信。

我曾经调试过一个分布式爬虫系统,用的就是消息传递模型。每个爬虫节点独立运行,通过消息队列交换任务和结果。好处很明显——一个节点挂了不影响其他节点,坏处是……消息格式设计不好,解析起来能让人崩溃。

Python 的 multiprocessing 模块支持消息传递:

from multiprocessing import Process, Queue

def worker(q):
    data = q.get()          # 接收消息
    result = data * 2
    q.put(result)           # 发送结果

if __name__ == '__main__':
    q = Queue()
    q.put(42)
    p = Process(target=worker, args=(q,))
    p.start()
    p.join()
    print(q.get())          # 输出 84

消息传递模型天然避免了数据竞争,因为根本没有共享数据。但代价是——每次通信都要序列化/反序列化数据,性能开销不小。

注意:消息传递不等于慢。在分布式系统中,消息传递是唯一可行的方案。共享内存在跨机器时根本玩不转。

3.3 数据并行 vs 任务并行

搞清楚了通信模型,咱们再看看并行计算的两种组织方式。

数据并行

把大数据拆成小块,每个核心处理一块,最后合并结果。说白了就是「人多力量大,一起搬砖」。

举个例子,对 100 万个数字求和:

from multiprocessing import Pool

def sum_chunk(chunk):
    return sum(chunk)

data = list(range(1_000_000))
chunks = [data[i:i+250_000] for i in range(0, 1_000_000, 250_000)]

with Pool(4) as p:
    results = p.map(sum_chunk, chunks)

total = sum(results)
print(total)  # 499999500000

数据并行最适合矩阵运算、图像处理、批量数据转换这类场景。我在做金融风控系统时,每天要处理上亿条交易记录,就是用数据并行把任务分到 32 个核上跑,时间从 40 分钟压到了 2 分钟以内。

任务并行

任务并行是把不同的任务分配给不同的核心。每个任务做的事情不一样,数据也可能不一样。

比如一个系统同时做三件事:下载数据、解析数据、写入数据库。这三个任务可以并行执行:

import threading
import time

def download():
    time.sleep(2)
    print("下载完成")

def parse():
    time.sleep(1)
    print("解析完成")

def write():
    time.sleep(3)
    print("写入完成")

threads = [
    threading.Thread(target=download),
    threading.Thread(target=parse),
    threading.Thread(target=write)
]

for t in threads: t.start()
for t in threads: t.join()
# 总耗时约 3 秒,而不是 6 秒

任务并行适合流水线作业、事件驱动系统、Web 服务器等场景。但要注意——任务之间如果有依赖关系,并行度会大打折扣。

选型指南:
  • 数据量大、操作相同 → 数据并行
  • 任务不同、相互独立 → 任务并行
  • 需要频繁通信 → 共享内存模型
  • 需要高容错、跨机器 → 消息传递模型

3.4 知识体系总览

下面这张图把本章的核心逻辑串起来了。我建议你多看几遍,把模型和并行方式对应起来:

多核编程模型知识体系 共享内存模型 消息传递模型 特点: • 数据直接共享 • 需要同步机制(锁、原子操作) • 适合单机多核场景 特点: • 数据不共享 • 通过消息通信 • 适合分布式/跨机器场景 数据并行 任务并行 • 数据拆分,相同操作 • 适合矩阵运算、批量处理 • 典型:MapReduce • 不同任务,独立执行 • 适合流水线、Web服务 • 典型:线程池

3.5 避坑指南

最后分享几个我踩过的坑:

  • 不要迷信共享内存——我曾经为了省去消息序列化的开销,强行用共享内存做跨机器通信,结果网络延迟和缓存一致性协议把我坑惨了。跨机器老老实实用消息传递。
  • 数据并行不是万能药——如果每个数据块的处理时间差异很大(比如有的块要 1 秒,有的要 10 秒),数据并行的效率会很低。这时候任务并行+动态调度更合适。
  • 锁的粒度要适中——锁太大,并行度低;锁太小,死锁风险高。我一般先粗粒度实现,性能瓶颈时再用细粒度锁优化。
一句话总结:共享内存模型适合「频繁交互、低延迟」的场景,消息传递模型适合「高容错、可扩展」的场景。数据并行解决「大计算」问题,任务并行解决「多任务」问题。选对模型,事半功倍。

可可精品资料库