你肯定在Go文档、技术贴纸、甚至一半的Go大会上见过那条铁律:“不要通过共享内存来通信,而要通过通信来共享内存。” 可当真正打开一个工业级Go代码库时,满眼看到的却是带sync.Mutex的结构体。谚语嘴上喊着通道,工程里却全是锁。到底谁错了?
其实谁都没错。这句话只是设计上的一个提示,而不是一个一刀切的lint规则。通道和互斥锁都是正确的Go,关键的区别在于你面对的问题究竟是“保护状态”还是“移交所有权”。这俩披着相似的外衣,却是截然不同的麻烦。
想清楚这两种麻烦,一切就通了。互斥锁守护的是一块数据。数据安安稳稳躺在原地,多个协程排队伸手进来,一次只许一个碰,碰完缩回去,谁也不真正拥有它,只是轮流进场。通道却是在转移所有权。数据要搬家,一个协程造出一个值,丢进通道,从此再也不能碰。接收方独占这份数据,直到它再把数据发往别处。任何时刻都只有一个协程持有这个值,根本没有“共享”这一说,自然也无需加锁。
用计数器场景一看便知。一个Counter结构体,里面嵌着sync.Mutex和一个int。Inc方法上锁、加一、解锁。Value方法上锁、读取、还锁。这个整数一辈子住在Counter里,所有调用Inc的协程都是去碰同一个数,轮流在锁下完成动作。接收者是指针,于是所有调用者共享同一把锁。代码干净、明确、快得毫不费力。
要是在这儿强行上通道,反而会把事情搞复杂。非得拉起一个专有协程来持有计数器,再搞一个请求通道,外加一个专门给Value用的回复通道。就为了保护一个int自增,凭空多出一堆活动零件。那句谚语从来没要求你这么干。
但如果换个场景——工作池处理任务,事情立刻就变了。每个Job在运行时只归一个worker所有,处理完后结果继续往下传,没有任何东西是共享的。这恰恰是转移所有权的问题,通道天生就为这种场景而生。
定义好Job和Result,worker从jobs通道里收任务,计算结果,再把Result塞进输出通道。整个流程里,数据一直都在移动,每个环节只有一个协程握有它。这时候再用互斥锁去排队保护共享状态,反而会丢了从“移交”中顺带获得的天然串行化红利。
“通过通信来共享内存”的真意,不在于用通道替换所有锁,而在于当你发现数据可以移动时,就别再把它当公共寄存柜。把数据装进通道,让所有权跟着数据流走,同步就变成了发送和接收本身。什么时候该用哪一把钥匙,看清面前那道门的锁孔形状,才是真正的Go手艺。
热门跟贴