这讲要解决什么
- 能区分网络延迟、节点崩溃与部分失败
- 会用状态机和不变量描述协议
- 解释线程、共享状态与锁的核心问题
- 按协议顺序推演channel 与条件同步
- 评估工程取舍:并发提高资源利用率,却扩大了可交错状态空间;RPC 简化接口,却不能消除网络失败语义。
从一个无法回答的问题开始:超时以后,服务器到底做没做
本讲最重要的直觉不是“Go 可以开很多 goroutine”,而是远程调用把控制流切断了。客户端执行本地函数时,进程要么继续、要么崩溃,通常可以知道返回前代码是否执行;RPC 超时却只告诉你在截止时间前没有收到响应。请求可能没离开客户端,可能到达服务器但尚未执行,可能已经修改状态而回复丢失。
把这三种世界写在纸上,客户端观察完全相同:timeout。任何重试策略都必须同时对三种世界正确。不重试会让第一种世界丢失可完成工作;重试会让第三种世界重复执行。这是后面去重表、幂等接口、事务 ID 和共识客户端语义的共同起点。
并发让不确定性进一步放大。服务器可能同时处理多个客户端,请求与重试可能交错;goroutine 在 RPC 等待期间不会冻结其他 goroutine,共享 map、序号和缓存仍在变化。因此这一讲先用 crawler 学会状态所有权与任务生命周期,再用 KV RPC 把同样的方法放到不可靠网络上。
课堂 notes 的 client/network/server 图不是通信概览,而是一张认识论边界图:跨过网络后,客户端无法直接观察服务器内部事件,只能通过协议消息和持久状态推断。后面所有分布式协议都在为这种不可观察性设计证据。
线程、共享状态与锁
Go 的 goroutine 共享同一地址空间,适合把等待网络、磁盘和计时器的工作重叠起来。共享内存也意味着操作会交错;只要一个不变量跨越多个字段或多条语句,就要用 mutex 让检查与更新成为一个临界区。不要在持锁时做可能长时间阻塞的 RPC,否则一个慢节点会冻结本地并发。
channel 与条件同步
channel 同时传递数据和同步关系,适合表达所有权转移、工作队列和结果汇聚。条件变量适合“状态受锁保护、条件暂时不成立”的场景:等待者必须在循环中重新检查条件,因为唤醒只表示状态可能改变。关闭 channel 是广播生命周期结束的信号,不等同于发送一个普通零值。
RPC 的表面与真实语义
RPC stub 负责编码参数、发送请求、等待响应并解码结果。调用者看见的像函数,但网络可能丢包、重复、延迟或重排;超时只能证明调用者没及时收到回复,不能证明服务器没有执行。服务端方法要显式设计重试语义,并把 request ID、缓存结果或幂等操作作为协议的一部分。
爬虫与 KV 示例
并发爬虫需要把“已访问集合”的检查与插入做成原子步骤,否则两个 goroutine 会重复抓取同一 URL。KV 服务则展示更难的问题:客户端在 Put 后丢失回复,重试可能造成重复写。线程正确性与分布式正确性是两层约束:前者保护本地状态,后者处理跨网络的不确定结果。
线程、事件循环与共享状态的真实成本
goroutine 是一条顺序执行流,拥有自己的程序计数器、寄存器和栈,但与同一进程内其他 goroutine 共享堆内存。它适合把“等待某个 I/O 的活动”写成看似顺序的代码:一个请求阻塞时,运行时可以调度另一个请求;在多核机器上,不同 goroutine 还可能真正并行。
事件驱动是另一种结构:单个循环保存每个活动的显式状态,根据输入事件推进一步。它避免大量线程及共享内存竞争,但把调用栈变成手工状态机,也不会自动得到多核并行。两者不是语义上的高低之分,而是把复杂度放在不同位置:线程把活动状态放在栈里,却要求同步共享数据;事件循环集中拥有状态,却要求程序员显式管理每个阶段。
数据竞争(data race)的定义不是“结果错了”,而是两个并发执行访问同一内存且至少一个写入,缺少同步关系。n = n + 1 包含读、加、写三个步骤,两个 goroutine 可能都读到旧值并覆盖彼此。更危险的是 Go map 等复杂结构在并发读写时内部不变量可能损坏或直接 panic。
锁保护的是程序员约定的不变量,而非变量与 Mutex 的语言级绑定。应在设计里写清“持有哪把锁时哪些字段可读写”,并让临界区覆盖检查与更新的整体,例如 crawler 的 test-and-set。只锁写入而把检查放在锁外,仍会让两个 goroutine 同时认为 URL 未访问。
WaitGroup、channel 与条件变量分别解决什么
互斥锁解决同一时刻谁能进入临界区,却不直接表达“某个条件何时成立”。WaitGroup 是计数型完成条件:在启动子任务前 Add(1),子任务退出时 Done(),等待方用 Wait() 阻塞到计数归零。Add 必须发生在 goroutine 启动之前,否则主 goroutine 可能先看到零并提前返回。
无缓冲 channel 的一次发送与一次接收同时完成,既传值又建立同步。crawler 的协调者独占 fetched map,因此无需锁;worker 只抓取页面并把链接切片发回。协调者用“已启动但尚未返回的 worker 数 n”判断结束:每收到一份结果先令 n--,每发现新 URL 并启动 worker 再令 n++,当 n 为零时整个可达图已探索完。
channel 并不天然消除死锁。无缓冲发送在接收方出现前会阻塞;如果协调者自己执行 ch <- result,却只有它随后才会接收,就会自我等待。关闭 channel 也只应由能证明“不会再有发送”的一方执行,多发送者随意 close 会引发 panic。
6.5840 实验常用 Mutex 加 condition variable,因为 Raft 等对象有一组共享状态字段,后台 goroutine 需要在“角色改变、日志有新条目、定时器到期”等条件上睡眠。选择工具时先写出状态所有权和唤醒条件:锁适合保护共享不变量,channel 适合传递所有权/事件,WaitGroup 适合一次性等待一组任务结束。
把上面的机制落到消息、状态与失败路径中。
页面链接可能形成 DAG 或环
在派生 goroutine 前原子去重
每个新任务有明确父节点
等待整棵执行树退出
把并发爬虫当成一个守恒问题,而不是 goroutine 技巧
爬虫的输入是可能有环的 URL 图,执行结构却必须是有限可回收的任务树。共享 seen 的作用不仅是性能去重,它还阻止环不断生成新任务。正确顺序是先在一个原子边界内检查并标记 URL,再启动子任务;先启动再标记会让两个父页面同时派生相同 URL。
使用 Mutex+WaitGroup 时,Add(1) 必须发生在启动 goroutine 之前。否则主 goroutine 可能在新任务登记前看到计数为零并返回。每条错误路径都必须 Done(),通常在 goroutine 开始处 defer。这里 WaitGroup 只表示生命周期,seen 的正确性仍由锁负责,两者解决不同问题。
使用 channel+单所有者时,可以让协调 goroutine 独占 seen,worker 不接触共享 map。此时要维护在途任务数 n:启动一个 worker 就 n++,收到一次完成事件就 n--,当 n=0 才结束。这个计数是一个守恒量,比“等 channel 暂时为空”可靠,因为 channel 为空可能只是 worker 仍在抓取。
比较两种实现不要只看代码行数。锁版本把图遍历状态分散到递归 goroutine 和共享表中;channel 版本把状态集中在协调循环,却需要显式跟踪在途任务。两者都必须证明:每个 URL 最多派生一次,每个已派生任务最终产生一次完成信号,主流程只在没有在途任务时退出。
RPC 把调用写得像本地,却不能拥有本地语义
RPC stub 把参数编码成 wire format,发送请求;服务器 dispatcher 找到 handler,解码、执行并返回响应。类型安全和自动序列化隐藏了报文细节,但网络故障仍穿透抽象。客户端看到超时,至少有三种可能:请求没到;请求到了但 handler 未执行完;handler 已完成而响应丢失。客户端无法仅凭超时区分它们。
因此重试策略决定语义。不重试接近 at-most-once 尝试,但可能丢失本来可完成的操作;无限重试提高可用性,却对非幂等操作造成重复。Put(k,v) 重复通常无害,Append(k,x)、转账或递增计数器重复会改变结果。所谓 exactly-once 不是 RPC 库开关,而需要客户端请求 ID、服务器去重表、结果缓存和崩溃后仍能恢复的持久状态共同实现。
Go RPC 的公开字段、方法签名和 reply 指针是接口契约;客户端与服务器不能共享指针意义上的内存。handler 必须避免在返回后继续修改 reply,序列化发生的时机和并发 handler 也应纳入考虑。服务器通常为每个请求并发运行 handler,因此即使客户端顺序调用,多个客户端也会并发访问服务状态。
网络通信还带来版本、容量和信任边界:参数必须可编码,超大消息会占用内存与带宽,旧客户端可能缺少字段,恶意或损坏输入不能被当作可信本地调用。课程实验的 RPC 模拟器聚焦丢包、延迟、重排与节点断连,但生产系统还要处理认证、限流和跨版本兼容。
把上面的机制落到消息、状态与失败路径中。
从重试一步步推导 at-most-once、at-least-once 与可恢复去重
先定义一次尝试:客户端发送一个请求并等待一个响应。完全不重试时,服务器最多因该次尝试执行一次,但客户端可能没有获得结果;这常被称为 at-most-once attempt,而不是业务操作的 exactly-once。超时持续重试可以让请求在网络最终恢复后至少到达一次,接近 at-least-once delivery,代价是服务器可能执行多次。
要把重复执行压回一次,客户端为每个逻辑操作分配 (clientID, sequence),所有重试保持相同编号。服务器在同一原子边界内检查去重表、执行状态更新、保存结果,再返回。第一次看到序号就执行;再次看到相同序号返回缓存结果;看到过旧序号则按接口契约拒绝或返回最后结果。
为什么要缓存结果而不仅记录“做过”?因为原请求可能是 Get 或 CompareAndSwap,客户端需要第一次执行时的准确回复;重新读取当前状态可能得到不同值。为什么去重表必须和业务状态一起持久化?若先更新值后崩溃、去重记录丢失,重启后相同请求仍会再执行。所谓 exactly-once effect 实际上是状态更新与去重证据共享一次可靠提交。
内存可以无限保存所有请求吗?生产系统通常假设每个客户端序号单调增加,只保留最新序号和结果;客户端也要避免并发发送无法由单个“最新值”表示的多个未完成请求。回收客户端状态需要 session/lease 或确认协议。由此可见,RPC 语义不是一个标签,而是一组接口、存储和生命周期假设。
审查一个并发服务器的四层清单
第一层是状态所有权:列出所有可变字段,标明由单一 goroutine 独占、由哪把锁保护,还是只能在初始化阶段写。不要用“基本不会同时访问”替代规则。第二层是原子操作边界:客户端观察到的一个逻辑操作可能需要检查、修改多个字段,它们必须在同一临界区或事务中保持不变量。
第三层是等待与锁顺序。阻塞 RPC、channel send、磁盘 I/O 或条件等待通常不应在持有全局锁时进行,否则其他 goroutine 无法推进产生你等待的事件。若必须同时持有多把锁,建立全局顺序并坚持;循环等待才是死锁的本质,不限于 Mutex,goroutine 也可通过 channel 或互相 RPC 形成等待环。
第四层是生命周期:谁创建 goroutine,谁让它退出,错误路径是否调用 Done,channel 由谁关闭,测试结束后后台循环是否泄漏。分布式实验反复创建和销毁节点,泄漏定时器或 goroutine 会跨测试污染状态,产生难以复现的拖尾失败。
验证时组合使用 race detector、超时、故障注入和重复运行。race detector 证明不了不存在高层逻辑竞争;无数据竞争也可能因锁覆盖范围不对而违反协议。日志应携带节点、任期/请求号和状态摘要,使你能找到最早的不变量破坏,而非只看到最终 RPC 超时。
把上面的机制落到消息、状态与失败路径中。
谁能写每个字段
哪些字段必须一起变化
锁、channel、RPC 是否成环
创建、退出、清理
把语义落实到 Lab 2:先写协议表,再写 handler
开始 Lab 2 前,为每个 RPC 写一张四列表:请求字段、允许重试方式、服务器原子状态变化、重复请求回复。Put(k,v) 即使重复结果相同,也仍要考虑并发 Put 的顺序;Append(k,s) 必须返回旧值且不能重复追加,因此尤其需要序号与结果缓存。不要等测试失败后才决定请求 ID 放在哪里。
锁应覆盖“查重—读旧值—写新值—记录结果”的整体。如果查重后解锁、执行更新时再加锁,两个相同重试会同时通过检查。RPC 发送和长期等待通常不在持有服务器全局锁时进行;客户端自己的序号分配若支持并发调用,也要有独立同步。
故障测试时记录逻辑请求 ID,而非每次网络尝试。你要看到的是 client 3 seq 8 发送了三次、服务器只执行一次、三次最终拿到同一回复。若日志只写“Append called”,重试会像三个不同业务请求,无法判断违反了哪条语义。
学完本讲应能把一个超时拆成多个可能世界,能为非幂等接口设计可恢复去重,能用所有权和守恒量证明 goroutine 会退出。后续 Raft 会让这些问题再出现一次,只是服务器状态不再是一份内存 map,而是被复制的状态机。
教案覆盖地图
覆盖口径:教师 notes/讲义原文逐行完整保留;中文教学单元覆盖课堂机制、失败路径与工程取舍;4/4 个显式板书占位已重绘;论文另设“问题—机制—证据—边界”阅读导航。覆盖不是用摘要替代原文,任何细节都可在页面末尾回查。
305 行 · 1,767 词 · 完整可搜索文本
183 行 · 422 词 · 完整可搜索文本
124 行 · 252 词 · 完整可搜索文本
198 行 · 1,347 词 · 完整可搜索文本
展开中文教学单元映射(12 项)
- 01从一个无法回答的问题开始:超时以后,服务器到底做没做
- 02线程、共享状态与锁
- 03channel 与条件同步
- 04RPC 的表面与真实语义
- 05爬虫与 KV 示例
- 06线程、事件循环与共享状态的真实成本
- 07WaitGroup、channel 与条件变量分别解决什么
- 08把并发爬虫当成一个守恒问题,而不是 goroutine 技巧
- 09RPC 把调用写得像本地,却不能拥有本地语义
- 10从重试一步步推导 at-most-once、at-least-once 与可恢复去重
- 11审查一个并发服务器的四层清单
- 12把语义落实到 Lab 2:先写协议表,再写 handler
论文要读到哪里
并发与 RPC 为什么把一次普通函数调用变成不确定事件?
goroutine、channel、锁和 RPC stub 共同建立并发控制流;请求 ID、重试与去重决定调用语义。
逐行追踪 crawler.go 的 goroutine 树和 kv.go 的客户端重试,标出每个阻塞点、共享状态与退出条件。
RPC 没有天然 exactly-once;超时只说明没有及时收到回复,不能证明服务器没有执行。
把直觉校准成不变量
RPC 超时意味着服务端没有执行请求。
超时只说明回复未按时到达;请求可能未到达、正在执行、已执行但回复丢失。
只记住正常路径就足以实现协议。
分布式协议的正确性主要由超时、重试、重排、崩溃恢复和旧消息路径决定。
知识检查
为什么 RPC 客户端重试 Put 时通常需要 request ID?
下列哪项最准确概括本讲的主要工程取舍?
为什么“RPC 超时意味着服务端没有执行请求。”是错误的?
离开本讲前,你应能复述
- Go 的 goroutine 共享同一地址空间,适合把等待网络、磁盘和计时器的工作重叠起来。
- 并发提高资源利用率,却扩大了可交错状态空间;RPC 简化接口,却不能消除网络失败语义。
- 超时只说明回复未按时到达;请求可能未到达、正在执行、已执行但回复丢失。
完整官方资料附录
以下是本讲对应官方材料的可搜索离线文本。中文精读负责解释;资料附录保留原始细节、例子、问答与代码,不以摘要替代原文。
课堂讲义notes/l-rpc.txt305 行 · 1,767 词 · 完整收录
6.5840 2026 Lecture 2: Threads and RPC
Topic: implementing distributed systems
... and Go programming for the labs
Go threads, and the web crawler
Go RPC
Details are Go specific but the concepts are important and widely used
Why Go?
good support for threads
convenient RPC
type- and memory- safe
garbage-collected (no use after freeing problems)
threads + GC is particularly attractive!
not too complex
Go is often used in distributed systems
After the tutorial, use https://golang.org/doc/effective_go.html
Threads
a useful structuring tool, but can be tricky
Go calls them goroutines; everyone else calls them threads
Thread = "thread of execution"
threads allow one program to do many things at once
each thread executes serially, just like a non-threaded program
the threads share memory
each thread includes some per-thread state:
program counter, registers, stack
Why threads?
I/O concurrency
Client sends requests to many servers in parallel and waits for replies.
Server processes many simultaneous client requests.
Each request may block.
While waiting for the disk to read data for client X,
process a request from client Y.
Multicore performance
Execute code in parallel on several cores.
Convenience
In background, once per second, check whether each worker is still alive.
Is there an alternative to threads?
Yes: write code that explicitly interleaves activities, in a single thread.
Usually called "event-driven."
Keep a table of state about each activity, e.g. each client request.
One "event" loop that:
checks for new input for each activity (e.g. arrival of reply from server),
does the next step for each activity,
updates state.
Event-driven can get you I/O concurrency,
and eliminates thread costs (which can be substantial),
but doesn't get multi-core speedup,
and is painful to program.
Threading challenges:
sharing data safely
what if two threads do n = n + 1 at the same time?
or one thread reads while another increments?
this is a "race"
= two threads use same memory at same time, one (or both) writes
often a bug
-> use locks (Go's sync.Mutex)
-> or avoid sharing mutable data
coordination between threads
one thread is producing data, another thread is consuming it
how can the consumer wait (and release the CPU)?
how can the producer wake up the consumer?
-> use Go channels or sync.Cond or sync.WaitGroup
deadlock
a cycle of threads waiting for each other
via locks, or channels, or RPC
Let's look at the tutorial's web crawler as a threading example.
What is a web crawler?
goal: fetch all web pages, e.g. to feed to an indexer
you give it a starting web page
it recursively follows all links
[diagram: pages, links, a DAG, a cycle]
but don't fetch a given page more than once
and don't get stuck in cycles
Crawler challenges
Exploit I/O concurrency
Network latency is more limiting than network capacity
internet latency: maybe 0.1 seconds, due to speed of light &c
internet throughput: maybe MB/sec or GB/sec
Fetch many pages in parallel
To increase URLs fetched per second
=> Use threads for concurrency
Fetch each URL only *once*
avoid wasting network bandwidth
avoid link cycles
be nice to remote servers
=> Need to remember which URLs visited
Know when finished
We'll look at three solutions [crawler.go on schedule page]
Serial
Concurrent, coordination via shared data
Concurrent, coordination via channels
Serial crawler:
performs depth-first exploration via recursive Serial calls
the "fetched" map avoids repeats, breaks cycles
a single map, passed by reference, caller sees callee's updates
finished when all [recursive] links are explored: easy
but: fetches only one page at a time -- slow
can we just put a "go" in front of the Serial() call?
what will happen?
let's try it... what happened?
ConcurrentMutex crawler:
Creates a thread for each page fetch
Many concurrent fetches, higher fetch rate
the "go func" creates a goroutine and starts it running
func... is an "anonymous function"
The threads share the fs.fetched map
So only one thread will fetch any given page
Why the Mutex (Lock() and Unlock()) in testAndSet()?
One reason:
Two threads make simultaneous calls to ConcurrentMutex() with same URL
Due to two different pages containing link to same URL
T1 reads fetched[url], T2 reads fetched[url]
Both see that url hasn't been fetched (fetched[url] = false)
Both fetch, which is wrong
The mutex causes one to wait while the other does both check and set
So only one thread sees fetched[url]==false
We say "the lock protects fs.fetched[]"
But note Go does not enforce any relationship between locks and data!
The code between lock/unlock is often called a "critical section"
Another reason:
Internally, map is a complex data structure (tree? expandable hash?)
Concurrent update/update may wreck internal invariants
Concurrent update/read may crash the read
defer...
What if I comment out Lock() / Unlock()?
go run crawler.go
Does it always work? Always fail? Why?
go run -race crawler.go
Detects races even when output is correct!
What if I forget to Unlock()? deadlock
How does the ConcurrentMutex crawler decide it is done?
sync.WaitGroup -- it's basically a counter
Wait() waits for all Add()s to be balanced by Done()s
i.e. waits for all child threads to finish
[diagram: tree of goroutines, overlaid on cyclic URL graph]
there's a WaitGroup per node in the tree
How many concurrent threads might there be?
ConcurrentChannel crawler
a Go channel:
a channel is an object
ch := make(chan int)
a channel lets one thread send an object to another thread
ch <- x
the sender waits until some goroutine receives
y := <- ch
a receiver waits until some goroutine sends
also: for y := range ch
channels both communicate and synchronize
several threads can send and receive on a channel
send+recv takes less than a microsecond -- fairly cheap
remember: sender blocks until the receiver receives!
"synchronous"
watch out for deadlock
ConcurrentChannel coordinator()
coordinator() creates a worker goroutine to fetch each page
worker() sends slice of page's URLs on a channel
multiple workers send on the single channel
coordinator() reads URL slices from the channel
At what line does the coordinator wait?
Does the coordinator use CPU time while it waits?
Note: there is no recursion here; coordinator() creates all workers.
Note: no need to lock the fetched map, because it isn't shared!
How does the coordinator know it is done?
Keeps count of workers in n.
Each worker sends exactly one item on channel.
The channel does two things:
1. communication of values.
2. notification of events (e.g. thread termination).
Why is it safe for multiple threads use the same channel?
Is this a race:
Worker thread modifies (creates) url slice, coordinator uses it?
* worker only writes slice *before* sending
* coordinator only reads slice *after* receiving
So they can't use the slice at the same time, so there's no race.
Why does ConcurrentChannel() create a goroutine just for "ch <- ..."?
Let's get rid of the goroutine...
When to use sharing and locks, versus channels?
Most (all?) problems can be solved in either style
What makes the most sense depends on how the programmer thinks
state -- sharing and locks
communication -- channels
For the 6.824 labs, I recommend sync.Mutex/sync.Cond for shared state
Remote Procedure Call (RPC)
a key piece of distributed system machinery; all the labs use RPC
goal: easy-to-program client/server communication
hide details of network protocols
convert data (strings, arrays, maps, &c) to "wire format"
portability / interoperability
RPC message diagram:
Client Server
request--->
<---response
Software structure
client app handler fns
stub fns dispatcher
RPC lib RPC lib
net ------------ net
Go example: kv.go on schedule page
A toy key/value storage server -- Put(key,value), Get(key)->value
Uses Go's RPC library
Common:
Declare Args and Reply struct for each server handler.
Client:
connect()'s Dial() creates a TCP connection to the server
get() and put() are client "stubs"
Call() asks the RPC library to perform the call
you specify connection, function name, arguments, place to put reply
library marshalls args, sends request, waits, unmarshalls reply
return value from Call() indicates whether it got a reply
usually you'll also have a reply.Err indicating service-level failure
Server:
Go requires server to declare an object with methods as RPC handlers
Server then registers that object with the RPC library
Server accepts TCP connections, gives them to RPC library
The RPC library
reads each request
creates a new goroutine for this request
unmarshalls request
looks up the named object (in table create by Register())
calls the object's named method (dispatch)
marshalls reply
writes reply on TCP connection
The server's Get() and Put() handlers
Must lock, since RPC library creates a new goroutine for each request
read args; modify reply
A few details:
Binding: how does client know what server computer to talk to?
For Go's RPC, server name/port is an argument to Dial
Big systems have some kind of name or configuration server
Marshalling: format data into packets
Go's RPC library can pass strings, arrays, objects, maps, &c
Go passes pointers by copying the pointed-to data
Cannot pass channels or functions
Marshals only exported fields (i.e., fields w/ CAPITAL letter)
RPC problem: what to do about failures?
e.g. lost packet, broken network, slow server, crashed server
What does a failure look like to the client RPC library?
Client never sees a response from the server
Client does *not* know if the server saw the request!
[diagram of losses at various points]
Maybe server never saw the request
Maybe server executed, crashed just before sending reply
Maybe server executed, but network died just before delivering reply
Remote procedure call doesn't behave the same as procedure call on a single machine!
A recurring challenge in implementing distributed systems
Simplest failure-handling scheme: "best-effort RPC"
Call() waits for response for a while
If none arrives, re-send the request
Do this a few times
Then give up and return an error
Q: is "best effort" easy for applications to cope with?
A particularly bad situation:
client executes
Put("k", 10);
Put("k", 20);
both succeed
what will Get("k") yield?
[diagram, timeout, re-send, original arrives late]
Q: is best effort ever OK?
read-only operations
operations that it's harmless to repeat
e.g. DB checks if record has already been inserted
Other common semantics: at-most-once
For example, Go RPC is a simple form of "at-most-once"
open TCP connection
write request to TCP connection
Go RPC never re-sends a request
So server won't see duplicate requests
Go RPC code returns an error if it doesn't get a reply
perhaps after a timeout (from TCP)
perhaps server didn't see request
perhaps server processed request but server/net failed before reply came back
Labs explore others way of implementing at-most-once
No retry is too restrictive for replicated servers
Like to retry at another replica if first replica failsGo 源码notes/crawler.go183 行 · 422 词 · 完整收录
package main
import (
"fmt"
"sync"
)
//
// Several solutions to the crawler exercise from the Go tutorial
// https://tour.golang.org/concurrency/10
//
//
// Serial crawler
//
func Serial(url string, fetcher Fetcher, fetched map[string]bool) {
if fetched[url] {
return
}
fetched[url] = true
urls, err := fetcher.Fetch(url)
if err != nil {
return
}
for _, u := range urls {
Serial(u, fetcher, fetched)
}
}
//
// Concurrent crawler with shared state and Mutex
//
type fetchState struct {
mu sync.Mutex
fetched map[string]bool
}
func (fs *fetchState) testAndSet(url string) bool {
fs.mu.Lock()
defer fs.mu.Unlock()
r := fs.fetched[url]
fs.fetched[url] = true
return r
}
func ConcurrentMutex(url string, fetcher Fetcher, fs *fetchState) {
if fs.testAndSet(url) {
return
}
urls, err := fetcher.Fetch(url)
if err != nil {
return
}
var done sync.WaitGroup
for _, u := range urls {
done.Add(1)
go func(u string) {
ConcurrentMutex(u, fetcher, fs)
done.Done()
}(u)
}
done.Wait()
}
func makeState() *fetchState {
return &fetchState{fetched: make(map[string]bool)}
}
//
// Concurrent crawler with channels
//
func worker(url string, ch chan []string, fetcher Fetcher) {
urls, err := fetcher.Fetch(url)
if err != nil {
ch <- []string{}
} else {
ch <- urls
}
}
func coordinator(ch chan []string, fetcher Fetcher) {
n := 1
fetched := make(map[string]bool)
for urls := range ch {
for _, u := range urls {
if fetched[u] == false {
fetched[u] = true
n += 1
go worker(u, ch, fetcher)
}
}
n -= 1
if n == 0 {
break
}
}
}
func ConcurrentChannel(url string, fetcher Fetcher) {
ch := make(chan []string)
go func() {
ch <- []string{url}
}()
coordinator(ch, fetcher)
}
//
// main
//
func main() {
fmt.Printf("=== Serial===\n")
Serial("http://golang.org/", fetcher, make(map[string]bool))
fmt.Printf("=== ConcurrentMutex ===\n")
ConcurrentMutex("http://golang.org/", fetcher, makeState())
fmt.Printf("=== ConcurrentChannel ===\n")
ConcurrentChannel("http://golang.org/", fetcher)
}
//
// Fetcher
//
type Fetcher interface {
// Fetch returns a slice of URLs found on the page.
Fetch(url string) (urls []string, err error)
}
// fakeFetcher is Fetcher that returns canned results.
type fakeFetcher map[string]*fakeResult
type fakeResult struct {
body string
urls []string
}
func (f fakeFetcher) Fetch(url string) ([]string, error) {
if res, ok := f[url]; ok {
fmt.Printf("found: %s\n", url)
return res.urls, nil
}
fmt.Printf("missing: %s\n", url)
return nil, fmt.Errorf("not found: %s", url)
}
// fetcher is a populated fakeFetcher.
var fetcher = fakeFetcher{
"http://golang.org/": &fakeResult{
"The Go Programming Language",
[]string{
"http://golang.org/pkg/",
"http://golang.org/cmd/",
},
},
"http://golang.org/pkg/": &fakeResult{
"Packages",
[]string{
"http://golang.org/",
"http://golang.org/cmd/",
"http://golang.org/pkg/fmt/",
"http://golang.org/pkg/os/",
},
},
"http://golang.org/pkg/fmt/": &fakeResult{
"Package fmt",
[]string{
"http://golang.org/",
"http://golang.org/pkg/",
},
},
"http://golang.org/pkg/os/": &fakeResult{
"Package os",
[]string{
"http://golang.org/",
"http://golang.org/pkg/",
},
},
}Go 源码notes/kv.go124 行 · 252 词 · 完整收录
package main
import (
"fmt"
"log"
"net"
"net/rpc"
"sync"
)
//
// Common RPC request/reply definitions
//
type PutArgs struct {
Key string
Value string
}
type PutReply struct {
}
type GetArgs struct {
Key string
}
type GetReply struct {
Value string
}
//
// Client
//
func connect() *rpc.Client {
client, err := rpc.Dial("tcp", ":1234")
if err != nil {
log.Fatal("dialing:", err)
}
return client
}
func get(key string) string {
client := connect()
args := GetArgs{ key }
reply := GetReply{}
err := client.Call("KV.Get", &args, &reply)
if err != nil {
log.Fatal("error:", err)
}
client.Close()
return reply.Value
}
func put(key string, val string) {
client := connect()
args := PutArgs{ key, val }
reply := PutReply{}
err := client.Call("KV.Put", &args, &reply)
if err != nil {
log.Fatal("error:", err)
}
client.Close()
}
//
// Server
//
type KV struct {
mu sync.Mutex
data map[string]string
}
func server() {
kv := &KV{data: map[string]string{}}
rpcs := rpc.NewServer()
rpcs.Register(kv)
l, e := net.Listen("tcp", ":1234")
if e != nil {
log.Fatal("listen error:", e)
}
go func() {
for {
conn, err := l.Accept()
if err == nil {
go rpcs.ServeConn(conn)
} else {
break
}
}
l.Close()
}()
}
func (kv *KV) Get(args *GetArgs, reply *GetReply) error {
kv.mu.Lock()
defer kv.mu.Unlock()
reply.Value = kv.data[args.Key]
return nil
}
func (kv *KV) Put(args *PutArgs, reply *PutReply) error {
kv.mu.Lock()
defer kv.mu.Unlock()
kv.data[args.Key] = args.Value
return nil
}
//
// main
//
func main() {
server()
put("subject", "6.5840")
fmt.Printf("Put(subject, 6.5840) done\n")
fmt.Printf("get(subject) -> %s\n", get("subject"))
}论文 FAQpapers/tour-faq.txt198 行 · 1,347 词 · 完整收录
Go FAQ
Q: Why does 6.5840 use Go for the labs?
A: Until a few years ago 6.5840 used C++, which worked well. Go works a
little better for 6.5840 labs for a couple of reasons. Go is garbage
collected and type-safe, which eliminates some common classes of bugs.
Go has good support for threads (goroutines), and a nice RPC package,
which are directly useful in 6.5840. Threads and garbage collection
work particularly well together, since garbage collection can
eliminate programmer effort to decide when the last thread using an
object has stopped using it. There are other languages with these
features that would probably work fine for 6.5840 labs, such as Java.
Q: are there any tips/tricks for building an intuition of how to build
effective Go code?
A: Get experience by writing Go code and reading other's people go
code. This page has many useful tips: https://go.dev/doc/effective_go
Q: Do goroutines run in parallel? Can you use them to increase
performance?
A: Go's goroutines are the same as threads in other languages. The Go
runtime executes goroutines on all available cores, in parallel. If
there are fewer cores than runnable goroutines, the runtime will
pre-emptively time-share the cores among goroutines.
Q: How do Go channels work? How does Go make sure they are
synchronized between the many possible goroutines?
A: You can see the source at https://golang.org/src/runtime/chan.go,
though it is not easy to follow.
At a high level, a chan is a struct holding a buffer and a lock.
Sending on a channel involves acquiring the lock, waiting (perhaps
releasing the CPU) until some thread is receiving, and handing off the
message. Receiving involves acquiring the lock and waiting for a
sender. You could implement your own channels with Go sync.Mutex and
sync.Cond.
Q: I'm using a channel to wake up another goroutine, by sending a
dummy bool on the channel. But if that other goroutine is already
running (and thus not receiving on the channel), the sending goroutine
blocks. What should I do?
A: Try condition variables (Go's sync.Cond) rather than channels.
Condition variables work well to alert goroutines that may (or may
not) be waiting for something. Channels, because they are synchronous,
are awkward if you're not sure if there will be a goroutine waiting at
the other end of the channel.
Q: How can I have a goroutine wait for input from any one of a number
of different channels? Trying to receive on any one channel blocks if
there's nothing to read, preventing the goroutine from checking other
channels.
A: Try creating a separate goroutine for each channel, and have each
goroutine block on its channel. That's not always possible, but when
it works it's often the simplest approach.
Otherwise try Go's select.
Q: When should we use sync.WaitGroup instead of channels? and vice versa?
A: WaitGroup is fairly special-purpose; it's only useful when waiting
for a bunch of activities to complete. Channels are more
general-purpose; for example, you can communicate values over
channels. You can wait for multiple goroutines using channels, though it
takes a few more lines of code than with WaitGroup.
Q: I need my code to perform a task once per second. What's the
easiest way to do that?
A: Create a goroutine dedicated to that periodic task. It should have
a loop that uses time.Sleep() to pause for a second, and then do the
task, and then loop around to the time.Sleep().
Q: How do we know when the overhead of spawning goroutines exceeds
the concurrency we gain from them?
A: It depends! If your machine has 16 cores, and you are looking for
CPU parallelism, you should have roughly 16 executable goroutines. If
it takes 0.1 second of real time to fetch a web page, and your network
is capable of transmitting 100 web pages per second, you probably need
about 10 goroutines concurrently fetching in order to use all of the
network capacity. Experimentally, as you increase the number of
goroutines, for a while you'll see increased throughput, and then
you'll stop getting more throughput; at that point you have enough
goroutines from the point of view of performance.
Q: How would one create a Go channel that connects over the Internet?
How would one specify the protocol to use to send messages?
A: A Go channel only works within a single program; channels cannot be
used to talk to other programs or other computers.
Have a look at Go's RPC package, which lets you talk to other Go
programs over the Internet:
https://golang.org/pkg/net/rpc/
Q: What are some important/useful Go-specific concurrency patterns to know?
A: Here's a slide deck on this topic, from a Go expert:
https://talks.golang.org/2012/concurrency.slide
Q: How are slices implemented?
A: A slice is an object that contains a pointer to an array and a start and
end index into that array. This arrangement allows multiple slices to
share an underlying array, with each slice perhaps exposing a different
range of array elements.
Here's a more extended discussion:
https://blog.golang.org/go-slices-usage-and-internals
I use slices often, and arrays never. A Go slice is more flexible than
a Go array since an array's size is part of its type, whereas a
function that takes a slice as argument can take a slice of any
length.
Q: What are common debugging tools people use for Go?
A: fmt.Printf()
As far as I know there's not a great debugger for Go, though gdb can be
made to work:
https://golang.org/doc/gdb
In any case, for most bugs I've found fmt.Printf() to be an extremely
effective debugging tool.
Q: When is it right to use a synchronous RPC call and when is it right to
use an asynchronous RPC call?
A: Most code needs the RPC reply before it can proceed; in that case it
makes sense to use synchronous RPC.
But sometimes a client wants to launch many concurrent RPCs; in that
case async may be better. Or the client wants to do other work while it
waits for the RPC to complete, perhaps because the server is far away
(so speed-of-light time is high) or because the server might not be
reachable so that the RPC suffers a long timeout period.
I have never used async RPC in Go. When I want to send an RPC but not
have to wait for the result, I create a goroutine, and have the
goroutine make a synchronous Call().
Q: Is Go used in industry?
A: Yes. You can see an estimate of how much different programming
languages are used here:
https://www.tiobe.com/tiobe-index/
Q: What are common problems that developers face when starting with Go?
A: Here are a few:
- Not protecting maps with locks when there is concurrent access. Use
Go's race detector!
- Deadlocks with channels.
- Not capturing a variable when creating a goroutine.
- Leaking goroutines.
Q: Does Go support inheritance? (In the Java/C++ kind of "extends" way?)
A: Go doesn't support C++ style inheritance but has generics,
interfaces, and embedded structs, which allow you to do many things
for which you would use inheritance in C++.
Q: The thing I found most confusing about the Go tutorial was that
goroutines don't continue executing after the main thread has
completed. I don't think this was mentioned explicitly anywhere in the
tutorial; I figured it out through debuging the crawler exercise.
A: Yes, I don't think it is in the tutorial, but the language spec is
explicit about this: https://golang.org/ref/spec (see Program
execution).
Q: I'm still a little confused about when to choose value or pointer
receivers. Can you provide any concrete/real-world examples of when we
would choose one over the other?
A: When you want to modify the state of the receiver, you have to use
pointer receivers. If the struct is very big, you probably want to
use a pointer receiver because value receivers operate on a copy. If
neither applies, you can use a value receiver. However, be careful
with value receivers; e.g., if you have a mutex in a struct, you
cannot make it a value receiver, because the mutex would be copied,
defeating its purpose.