谈论一下Go语言,和接下来的lab中对分布式编程最有用的machinery。
Go内存安全,对线程、锁和同步有良好支持,有一个方便的RPC包。接下来的课程和程序中会经常用到RPC,用来让不同机器之间通信。相比之下C++中线程和内存回收问题极为复杂。
线程是管理并发的主要工具,Go中称为协程(Goroutine),Go中启动入口main函数本身就是一个协程。
使用协程的原因:
- 并发I/O
Go每个线程可以通过RPC同时对网络上不同服务器发送请求和等待回复。 - 多核并行
利用好多核处理器。 - 后台任务的便利性
后台进行周期性任务,例如主服务器周期性检查worker是否存活。在之后的lab中会大量用到这个方式。
除了线程,还可以考虑事件驱动编程。它通常是一条线程和一个循环,循环等待可能会触发操作的事件,但是无法使用多核并行能力。而且通常来说,线程并行更方便,但是线程非常多时需要维护每个线程栈和调度表,开销会更大。
或许可以考虑每个线程运行一个事件驱动编程。
写线程代码时会遇到的挑战:
- 如何处理共享数据
线程共享地址空间,非原子操作并发时容易出错。此时就需要对非原子操作共享数据加锁。
在编程过程中锁的问题(即线程race)也许不容易发现,Go中提供了race检测工具,在运行时加上-race
参数即可。
race检测的原理是跟踪线程近期读写内存位置,发现有不同线程读写同一位置且没有锁时会进行提醒。但是它会使用大量内存,且无法检测静态的未执行代码,使用时需要建立测试标准且让所有代码都执行。 - 线程协作
有时候需要线程之间进行交互,例如传输数据。Go中的channel
可以实现这一点。Sync.conf()
也是个好办法,即唤醒信号。Sync.waitgroup
适合启动已知数量的Goroutines。 - 线程死锁
互相等待。在锁设置不合理时容易出现。
实际使用中需要对线程数量进行限制,一种办法是创建固定大小的worker池(线程池),复用线程而不是每次创建一个新线程。
多线程的官方例子:web crawler
网络爬虫的工作:爬取第一个页面,提取所有url,进入url重复这个工作。但是有些url会重复嵌套,所以需要记录已经爬取过的页面。为了提高效率需要并行启动多个爬虫,直到占满带宽。
最难的部分在于,爬虫何时完成结束。
- 串行爬虫程序:使用递归调用深度优先搜索,维护了一个叫
fetched
的map,记录已经爬过的页面。 - 一种并行爬虫程序,在读取fetched时需要加锁。**函数传map的引用而不是传值。**使用了
waitgroup
防止主进程退出。为了防止协程中panic,可以使用defer处理异常(例如出问题后执行defer done防止waitgroup无法退出)。注意waitgroup的done操作本就是有锁的,所以不会出现抢占。 - 另一种并行程序使用channel来共享信息,就不需要锁。主线程master循环读取channel中的数据,维护fetched表。worker进程爬取url把信息送入channel。这里map只有master进程读写,所以不存在抢占问题。channel内部有锁,也不会发生多线程race问题。
官方代码文件: