第 29 章 / 共 58 章 卷六 并发

channel 与队列族

第 28 章的路线是"共享内存 + 手动加锁":威力大,责任也大。本章换一条更省心的路线——用通信代替共享。channel 把缓冲、互斥、等待唤醒三件事打包进一个类型;队列族(SpscQueue/MpscQueue/MpmcQueue/WaitQueue)则为固定拓扑提供更低开销的无锁通道。容量的 2 的幂取整、关闭后的排空规则、流水线死锁现场——全部实测。

本章导读

第 28 章结尾预告了"另一条路线",本章兑现。回顾一下 28.7 用 cond 做生产者消费者要凑齐的三件套:一个队列容器存数据、一把 mutex 保护它、一个 cond 传达"有货了/有位了"的通知,外加 while 谓词循环防虚假唤醒——每件都对,拼起来才对一个,漏一件就是事故。channel<T> 把这三件事收进一个类型:它自带缓冲、自带互斥、自带等待唤醒,send/recvInto 两个调用就是全部协作协议。如果说第 28 章教你"怎么造锁",本章就是给你一把"已经造好的锁"——而且锁的钥匙孔设计得让你很难插错。本章依次讲:创建与容量(实测发现容量向上取整到 2 的幂)、六个收发操作、阻塞与关闭的完整语义、七种合法元素类型、与 thread 的经典配合模式,然后是标准库队列族四种(它们比 channel 更轻,但各司其职),最后用一个三级流水线实战收官,并复盘一次真实踩出来的流水线死锁。

本章学习目标

会用 channel::<T>(容量) 创建通道并理解容量的 2 的幂取整规则;掌握 send/trySend/recvInto/tryRecvInto/recvTimeoutInto 五个收发操作与 count/isClosed 两个观察操作(全部返回 bool 的语义);理解关闭后的完整行为(拒绝发送、排空缓冲、唤醒阻塞接收、幂等);知道七种合法元素类型与 class/func 等被拒原因;会用"多生产者计数关门"与"while recvInto 优雅退出"两个模式;分清 SpscQueue/MpscQueue/MpmcQueue/WaitQueue 的方法集差异与适用拓扑;能识别并避开缓冲流水线死锁。

29.1 从锁到通道

并发编程有句老话:不要靠共享内存来通信,要靠通信来共享内存。第 28 章走的是前半句的反路——所有线程盯着同一块内存,用锁维持秩序。这条路没有错(28 章的数素数实战就是这么赢的),但它把"哪些数据共享、哪段代码持锁"的分析负担全压在你肩上,28.4 的三张竞态面孔就是分析失误的代价。

channel 走后半句:数据不再共享,而是从一个线程手里复制一份、递交到另一个线程手里。发送方送出去之后不再拥有那份消息,接收方拿到的是自己的副本——竞态的温床(共享可变状态)在结构上就不存在了。协作协议也简化成一句话:有货就收,没货就等;关门就走。

代价同样真实:消息要复制(29.6 与深入细节 2),吞吐受缓冲容量与调度影响。所以本章后半的队列族提供了"我知道我的拓扑,给我最轻的管子"的选项——单生产单消费就不必付多生产者竞争的同步成本。选型见 29.9。

29.2 创建与容量

声明类型 channel<T>,构造用泛型调用 channel::<T>(容量):

create.xl
channel<int> ch = channel::<int>(2)    // 显式容量
channel<int> def = channel::<int>()     // 省略容量:默认 64 槽
auto autoCh = channel::<int>(1)          // auto 推断同样可行

容量的真实语义是本章第一个实测发现:容量会被向上取整到 2 的幂,最小为 2;传 0 或省略等价于默认 64。逐档实测(用 trySend 打满计数):

声明容量0(或不传)123458100
实际槽位64224488128

为什么取整到 2 的幂?因为底层是无锁环形缓冲,下标回绕用位与代替取余(深入细节 1)。写代码时不要依赖精确容量——把容量当"缓冲量级"而非"精确上限":想要紧背压就给小容量(1 也至少有 2 槽),想要削峰就给几十上百。

容量参数必须是整数,构造函数最多接收一个容量参数,违反均在编译期拦下(报错原文见常见错误)。

29.3 收发六操作

channel 的全部收发 API 是五个 bool 方法加一个超时变体,搭配两个观察方法:

ops.xl
channel<int> ch = channel::<int>(2)

// 发送:阻塞版与非阻塞版
bool ok1 = ch.send(42)        // 满则等待空间;已关闭返回 false
bool ok2 = ch.trySend(43)     // 满或已关闭立即返回 false

// 接收:目标必须是可以写入的具名变量,类型与元素类型完全一致
int v = 0
bool got1 = ch.recvInto(v)        // 空则阻塞等待;关闭且排空返回 false
bool got2 = ch.tryRecvInto(v)     // 空立即返回 false

// 超时接收:ms < 0 等价阻塞,ms = 0 立即检查,ms > 0 限时等待
bool got3 = ch.recvTimeoutInto(v, 100)   // 最多等 100ms

// 观察
println(ch.count())      // 当前缓冲消息数(瞬时值)
println(ch.isClosed())   // 是否已关闭

三个要点实测确认。第一,recvTimeoutInto 超时不碰目标变量:v 预置 999,空队列超时返回 false 后 v 仍是 999——可以安全复用接收变量。第二,ms 三态:-1 等价 recvInto 阻塞(实测另一线程 100ms 后送值成功接到),0 等价 tryRecvInto 立即检查,正数限时。第三,count() 是瞬时值:多线程下刚读完就可能过时,只能用于观测与调试,不能当同步依据——"count() > 0 所以 recvInto 不会阻塞"这种推理在并发下不成立。

所有接收变体的第一个参数都必须是具名、可写、类型精确匹配的变量:const 变量、闭包捕获变量、类型不符都会被编译期拦下(坑 3、坑 4)。这个限制是有意的——通道那头是另一个线程,写入目标必须是一块生命周期明确、独占可写的存储。

29.4 阻塞实录

"阻塞"不是形容词,是线程真的停下来等。两组实测为证。第一组:容量 2 的 channel 先填满,主线程再 send 第三个——它会卡在 send 里,直到 300ms 后消费者醒来取走一个腾出空间:

blocking.xl(输出顺序即证据)
[main] filled, count=2
[main] send(3) begin (queue full)
[consumer] woke up, start recv      // 300ms 后消费者醒来
[consumer] got 1                    // 取走一个,腾出空间
[main] send(3) returned true        // 主线程的 send 此刻才返回

如果 send 不阻塞,"send(3) returned"会立刻出现在 begin 之后。实测顺序证明主线程老老实实等了消费者一手。

第二组:消费者在空 channel 上 recvInto 干等,主线程 100ms 后直接 close——close 会唤醒所有阻塞中的接收者,recvInto 返回 false,线程得以收拾退出(exitCode -1 是我们约定的"被关门"标记):

closewake.xl
channel<int> ch = channel::<int>(1)
auto waiter = func() as int
    int v = 0
    if ch.recvInto(v) then   // 空队列:阻塞在此
        return v
    end
    return -1                // 被 close 唤醒走到这里
end
thread t = thread(waiter)
time.sleep(100)
ch.close()
println(t.waitTimeout(2000))   // true:线程被唤醒并退出
println(t.exitCode())          // -1

这组"关门唤醒"语义是 channel 优雅退出的地基:消费者不需要轮询、不需要退出标志位,while ch.recvInto(v) 一个循环就是完整的生命周期(29.7)。

29.5 关闭语义

close 之后的 channel 行为规则实测完整,四条全部可以直接依赖:

操作关闭后的行为
send / trySend立即返回 false,消息不进入队列
recvInto / tryRecvInto(缓冲有货)照常接收——已入队消息继续排空,一条不丢
recvInto / tryRecvInto(缓冲已空)立即返回 false,不再阻塞
close(再次调用)幂等:第二次 close 无副作用,isClosed 保持 true

这套"关而不丢"的语义正是广播式终止信号:生产者关门的瞬间,它等于向所有消费者宣布"货就这些了,收完下班"——已在队列里的货照样送达,消费者收完最后一条自然拿到 false 退出。

另外两条生命周期规则:channel 是 move-only 资源句柄(与 thread、mutex 同族),不能聚合进容器或当普通值复制;离开作用域时自动关闭队列并释放全部未消费消息——忘记 close 不会泄漏句柄,但等待中的消费者也就永远等不到那个 false 了,所以"谁关门"仍是设计的一部分(29.7 给出标准答案)。

29.6 元素类型

channel<T> 当前允许七种元素:bool、整数、浮点、string、time、point、var——共同点是都具备稳定的消息复制语义。实测全家福:

types.xl
channel<string> cs = channel::<string>(2)
string src = "hello"
cs.send(src)
src = "changed"           // 发送后修改原变量
string got = ""
cs.recvInto(got)
println(got)              // hello:队中消息不受影响

channel<var> cv = channel::<var>(4)
cv.send(123)               // var 通道可以混装类型
cv.send("abc")
cv.send(1.25)
var x = null
cv.recvInto(x)
println(int(x))          // 123
cv.recvInto(x)
println(string(x))       // abc

复制语义是 channel 安全性的核心:普通值发送时复制进消息箱;string 发送时立即复制 UTF-8 字节、接收线程里重建;var 发送时提升为共享 xvalue、接收方接管新值。一句话——消息一旦出手,就与发送方再无瓜葛,这正是"靠通信共享内存"的落地方式(实现证据见深入细节 2)。

被拒之门外的是另一类:class、func、generator<T>、array<int> 等涉及复杂对象生命周期或 move-only 句柄的类型,语义阶段直接拒绝(坑 1)。class 是引用语义句柄(第 21、27 章),让它过通道等于重新引入共享——与 channel 的设计哲学相悖,所以干脆不许。想让两个线程共享一个 class 实例?那就回到第 28 章:传句柄走共享路线,自己配锁。

29.7 与 thread 的配合

channel 与 thread 是天作之合,两个模式覆盖八成场景。模式一,工厂闭包发 channel:channel 声明在顶层,工厂闭包捕获它(第 28 章的工厂模式原样适用):

pattern.xl(多生产者计数关门,实测通过)
channel<int> ch = channel::<int>(8)
int producerDone = 0
mutex m = mutex()

func makeProducer(int base) as func() as int
    auto p = func() as int
        for i in range(1, 6)
            ch.send(base + i)
        end
        m.lock()
        producerDone += 1
        bool last = producerDone == 3
        m.unlock()
        if last then
            ch.close()              // 最后一个完成的生产者关门
        end
        return 0
    end
    return p
end

auto consumer = func() as int
    int v = 0
    int sum = 0
    while ch.recvInto(v)   // 模式二:关闭且排空后 false,循环自然结束
        sum += v
    end
    return sum
end

thread c = thread(consumer)
thread p1 = thread(makeProducer(0))
thread p2 = thread(makeProducer(100))
thread p3 = thread(makeProducer(200))
p1.wait()
p2.wait()
p3.wait()
c.wait()
println(c.exitCode())   // 1545:三个生产者 15 条消息一条不差

模式二,while recvInto 优雅退出:消费者的整个生命周期就是一个 while 循环,没有退出标志、没有轮询、没有 sleep 试探。这两个模式合起来的要点是"关门的人要可数":单生产者直接关;多生产者用计数器认出自己的最后一个;生产者数量都不可数时,就由第三方(主线程 wait 完所有生产者后)关。

一个顺口提醒:for-in 遍历 channel 是不支持的(坑 5),while recvInto 就是官方的"遍历到关门"写法。

29.8 队列族四种

标准库还提供四个编译器识别的泛型原生队列:SpscQueue<T>(单生产单消费)、MpscQueue<T>(多生产单消费)、MpmcQueue<T>(多生产多消费)是有界并发队列,WaitQueue<T> 是支持阻塞与超时等待的等待队列。构造统一为 XxxQueue::<T>(容量):

queues.xl
MpmcQueue<string> q = MpmcQueue::<string>(64)
q.tryPush("work")                // 非阻塞投递,满返回 false
string work = ""
q.tryPopInto(work)                 // 非阻塞领取,空返回 false

WaitQueue<int> waits = WaitQueue::<int>(64)
int v = 0
waits.popInto(v)                   // 阻塞领取:空则等待(仅 WaitQueue 有)
waits.popTimeoutInto(v, 1000)      // 限时等待 1000ms

方法集实测有一个与文档口径的出入要钉死:阻塞式的 popInto/popTimeoutInto 只存在于 WaitQueue,三种无锁队列只有非阻塞的 tryPush/tryPopInto,调用 popInto 会吃 [sem] unknown queue method。完整方法集对照:

方法Spsc / Mpsc / MpmcWaitQueue
tryPush(value)✓✓
tryPopInto(out)✓✓
popInto(out) 阻塞✗(unknown queue method)✓
popTimeoutInto(out, ms)✗(unknown queue method)✓
count / close / isClosed / isDrained✓✓

其余语义实测:容量与 channel 一样向上取整到 2 的幂(1→2、3→4、5→8、100→128);isDrained = 已关闭且已排空——关闭但队列里还有残留时 isDrained 是 false,残留可以照常 tryPopInto 取出,取空后才变 true;close 后 tryPush 返回 false;close 会唤醒 WaitQueue 上阻塞的 popInto(返回 false)。元素类型支持 bool/整数/浮点/string/time 等,string 同样是复制语义;var 元素当前有坑——声明 Queue<var> 运行期段错误(退出码 139,check 阶段不拦),请勿使用(已按编译器缺陷上报)。

并发正确性实测:SpscQueue 单生产单消费 5000 条,消费和 12502500 分毫不差;MpscQueue 三生产一消费 3000 条,和 31501500 精确;MpmcQueue 四生产两消费 8000 条、加锁汇总 128004000,与理论值完全一致、队列 leftover=0。拓扑选对,它们就是即插即用的无锁通道。

29.9 选型对照

五条路线摆在一起,按拓扑与需求挑:

方案阻塞等待关闭广播开销适用场景
channel<T>✓(还有超时)✓中通用线程通信,拓扑不定或需要优雅终止
SpscQueue<T>✗✓(close/isDrained)最低固定一对一管道:解码线程→渲染线程
MpscQueue<T>✗✓低多源汇入单点:日志收集、事件上报
MpmcQueue<T>✗✓中工作窃取式任务池,消费者自旋领取
WaitQueue<T>✓(还有超时)✓中要阻塞领取但不需要 var 元素的任务队列
mutex + 容器 + cond(28 章)✓手动取决于实现需要优先级、批量取出、自定义谓词等非标语义

经验法则三条。一,默认从 channel 起步:阻塞语义省掉自旋烧 CPU,close 广播省掉退出标志,代码最短。二,拓扑固定且性能敏感再上无锁队列:它们没有阻塞接收,消费者空转时得自己处理(自旋、sleep 或配合 sem)。三,需要"拿走消息时还要看一眼挑一挑"才回 28 章手工三件套——channel 与队列族的接口都是严格 FIFO,没有优先级与条件领取。

实战:三级流水线

把全章串起来:生产者线程发 20 个任务,三个工人从 jobs 领任务、平方后投进 results,主线程汇总。工人们在 jobs 关门后排空退出,最后一个退出的工人负责关 results——每个 channel 的关门人都"可数",全程无一处显式加锁保护数据:

pipeline.xl(实测通过)
channel<int> jobs = channel::<int>(8)
channel<int> results = channel::<int>(8)
int workerDone = 0
mutex m = mutex()

auto producer = func() as int
    for i in range(1, 21)
        jobs.send(i)
    end
    jobs.close()                 // 发完即关门:通知工人退出
    return 20
end

func makeWorker() as func() as int
    auto w = func() as int
        int job = 0
        int processed = 0
        while jobs.recvInto(job)         // jobs 关闭排空后退出
            results.send(job * job)
            processed += 1
        end
        m.lock()
        workerDone += 1
        bool last = workerDone == 3
        m.unlock()
        if last then
            results.close()               // 最后一个工人关结果通道
        end
        return processed
    end
    return w
end

thread p = thread(producer)
thread w1 = thread(makeWorker())
thread w2 = thread(makeWorker())
thread w3 = thread(makeWorker())

int got = 0
int total = 0
int sumSq = 0
while results.recvInto(got)   // results 关闭排空后退出
    sumSq += got
    total += 1
end

p.wait()
w1.wait()
w2.wait()
w3.wait()
println("results=" + string(total) + " sumSq=" + string(sumSq))
// results=20 sumSq=2870(1²+2²+…+20² = 2870,分毫不差)
println("workers: " + string(w1.exitCode()) + "/" + string(w2.exitCode()) + "/" + string(w3.exitCode()))
// workers: 12/4/4(任务谁抢到算谁的,负载自然不均但总量守恒)

对比第 28 章 cond 版的同类程序:没有了队列容器、没有了谓词循环、没有了 signal 时机斟酌,锁只剩一把(给 workerDone 计数用)。但别把"结构简单"误解为"随便连都不会死"——这个流水线在写成"主线程先发完 jobs 再收 results"时真实死锁过一次,复盘见深入细节 3。

深入细节

1. 容量为什么取整到 2 的幂

channel 与四种队列的容量取整(1→2、3→4、100→128)指向同一个实现:无锁环形缓冲。环形缓冲每次下标加一要回绕,通用写法是取余 (i+1) % n;而当 n 是 2 的幂时,取余可以换成位与 (i+1) & (n-1)——无锁队列的心跳路径上每一次读写都要做这步运算,位与比取余快且对 CPU 流水更友好。cgen 证据:channel::<int>(2) 编译为 xrtChannelCreate(iCapacity),容量原样传入 XRT 的 xchannel,取整在运行时库里完成。所以"声明 100 实得 128"不是 bug,是无锁实现的通行做法——代价是你要放弃对精确容量的假设。

2. 消息箱:发送即复制

channel 凭什么敢让两个线程不抢数据?cgen 生成的 C 代码给出答案:send 时运行库 xrtMalloc(iSize) 分配一个消息箱,memcpy(pBox, pValue, iSize) 把值整个复制进去,箱指针入队;recv 时取出箱指针、把内容写进你的目标变量、释放箱。队列里流动的从来不是"你的变量",而是"值的独立副本"——这就是 string 发送后改原变量不影响队中消息的原因,也是 class 被拒的原因:class 句柄指向共享对象,复制句柄不等于复制对象,放行就违背了消息语义。var 走的是另一条路:发送时提升为 XRT 共享 xvalue,接收方接管新值并释放旧值,配合引用计数保证跨线程生命周期。三种接收共用一个选择器 xl6_channel_recv_select:timeoutMs >= 0 走 xrtChannelRecvTimeout,否则按阻塞标志走 xrtChannelRecv / xrtChannelTryRecv。

3. 实战死过一次:缓冲流水线死锁

本章实战的初版把生产放在主线程——"先 for 循环发完 20 个 jobs,再开始收 results"——结果真实挂死(timeout 强制终止,退出码 124)。死锁结构:jobs 只有 8 槽,工人领到任务后往 results 送,results 也只有 8 槽;主线程不发完 jobs 就不收 results,于是 results 很快填满,工人阻塞在 results.send;工人不取了,jobs 跟着填满,主线程阻塞在 jobs.send——主线程等工人腾 jobs,工人等主线程收 results,标准的环形等待。教训两条:流水线每一级的发送方与接收方必须同时活着(实战版把生产者也放进线程,主线程专职收 results,死锁立刻消失);缓冲容量不是保险丝——再大的容量也只是把死锁推迟到第 N 条消息。这与 28.9 的锁序死锁同源:凡是环形等待,不论环是用锁还是队列搭的,都会死。

⚠️ 常见错误

坑 1:给 channel 装 class 元素
bad.xl
class Box
    int value = 0
end
channel<Box> ch = channel::<Box>(1)

编译报错:bad.xl:5:29: [sem] channel<T> 当前只支持 bool、整数、浮点、string、time、point 等具备稳定消息复制语义的元素类型(第 5 行)。class 是引用语义句柄,复制句柄不等于复制对象;func、generator、array<int> 同样被拒。正确做法:跨线程共享 class 请走第 28 章的共享+锁路线;要走 channel,就把要传的字段拆成值消息。

坑 2:send 的值类型与元素类型不符
bad.xl
channel<int> ch = channel::<int>(1)
bool ok = ch.send("bad")

编译报错:bad.xl:2:19: [sem] channel.send 参数类型必须匹配 channel<T> 的元素类型: target int, value string(第 2 行)。channel 是静态类型通道,不做隐式转换。想混装类型,显式声明 channel<var>,接收端用 int()/string() 等显式取出。

坑 3:recvInto 的目标变量不合格
bad.xl
channel<int> ch = channel::<int>(1)
float value = 0.0
bool ok = ch.recvInto(value)

编译报错:bad.xl:3:23: [sem] channel.recvInto 目标变量类型必须等于 channel<T> 的元素类型: target int, value float(第 3 行)。目标类型必须与元素类型完全一致,int 通道配 float 变量也不行。另外两类常见不合格目标:const 变量报 [sem] const 不能被重新赋值;匿名函数里捕获外层变量当目标报 [sem] 捕获变量在匿名函数体内只读——在线程闭包里收消息时,先声明一个函数内的局部变量作目标。

坑 4:给 channel 调不存在的方法
bad.xl
channel<int> ch = channel::<int>(1)
bool ok = ch.wait()

编译报错:bad.xl:3:14: [sem] unknown channel method(第 3 行)。channel 没有 wait——那是 thread 的方法。channel 的方法全集就是 29.3 的七个(send/trySend/recvInto/tryRecvInto/recvTimeoutInto/count/isClosed)加 close。同样的报错也会在给 SpscQueue 等无锁队列调 popInto 时出现([sem] unknown queue method,29.8)。

坑 5:用 for-in 遍历 channel
bad.xl
channel<int> ch = channel::<int>(4)
for v in ch
    println(v)
end

编译报错:bad.xl:4:10: [sem] for in iterable type is not supported(第 4 行)。channel 不是可迭代容器——"收到关门为止"的遍历要用 while ch.recvInto(v)(29.7 模式二),它天然在关闭排空后结束循环。

本章小结

主题要点
创建channel::<T>(容量);容量向上取整到 2 的幂、最小 2,0/省略 = 64
发送send 阻塞(满等待、关闭 false);trySend 非阻塞(满/关闭立即 false)
接收recvInto 阻塞;tryRecvInto 立即;recvTimeoutInto 的 ms<0 阻塞、=0 立即、>0 限时,超时不碰目标
关闭拒绝发送、缓冲照常排空、排空后接收 false、唤醒阻塞接收、幂等;离开作用域自动关闭
元素类型bool/整数/浮点/string/time/point/var;class/func/generator/array<T> 被拒;发送即复制
配合模式工厂闭包发 channel;多生产者计数关门;while recvInto 优雅退出
队列族Spsc/Mpsc/Mpmc 只有 tryPush/tryPopInto;WaitQueue 另有阻塞 popInto/popTimeoutInto;isDrained = 关闭且排空;<var> 段错误勿用
选型默认 channel;固定拓扑性能敏感上无锁队列;非标语义回 mutex+cond 三件套

复习题

  1. channel::<int>(3) 实际有多少个槽位?channel::<int>(100) 呢?为什么实现要做这种取整?
  2. channel 关闭之后,send、缓冲未排空的 recvInto、缓冲已排空的 recvInto 各自什么表现?close 再调一次会怎样?
  3. send 与 trySend、recvInto 与 tryRecvInto 的区别各是什么?什么场景下 trySend 返回 false 比 send 阻塞更有用?
  4. recvTimeoutInto(v, ms) 的 ms 取负数、零、正数时分别是什么语义?超时返回 false 时 v 会被改动吗?
  5. 为什么 channel 拒绝 class 元素却能接受 string?从消息复制语义角度解释。
  6. 单生产单消费的固定管道,SpscQueue 与 channel 都能胜任,两者各付出什么、得到什么?

编程练习

练习 1:背压观察员

建一个容量 4 的 channel<int>,慢消费者(每条 sleep 5ms)收 20 条;生产者先用 trySend 投 20 条,统计有多少次撞上"队列已满",再把失败的消息改用 send 阻塞补投,验证 20 条一条不丢。参考结果:快生产慢消费下实测撞满 10 次,改 send 后背压生效,produced=20、consumed=20。

练习 2:乒乓球

用两个 channel<string>(ping 与 pong)实现主线程与工人线程的五个回合:主线程发 "ping#N"、收 "pong#N";工人用 recvTimeoutInto 收球(超时兜底 2000ms),收到第 N 球回 "pong#N"。主线程发完 5 球后 close(ping) 让工人退出。参考结果:5 个回合依次对齐打印,工人 exitCode=5。

练习 3:WaitQueue 任务队列

用 WaitQueue::<int>(16) 实现:主线程 tryPush 任务 1..10,两个工人各用 popTimeoutInto(2000ms 兜底)领 5 个任务并加锁汇总。验证总和 55、两个工人各领 5 个。提示:WaitQueue 有阻塞领取,工人不需要自旋。