注:学习《Go语言圣经》笔记,PDF点击下载,建议看书。
Go语言小白学习笔记,书上的内容照搬,大佬看了勿喷,以后熟悉了会总结成自己的读书笔记。
网络编程是并发大显身手的一个领域, 由于服务器是最典型的需要同时处理很多连接的程序, 这些连接一般来自远彼此独立的客户端。 在本小节中, 我们会讲解go语言的net包, 这个包提供编写一个网络客户端或者服务器程序的基本组件, 无论两者间通信是使用TCP, UDP或者Unix domain sockets。 在第一章中我们已经使用过的net/http包里的方法, 也算是net包的一部分。
我们的第一个例子是一个顺序执行的时钟服务器, 它会每隔一秒钟将当前时间写到客户端:
gopl.io/ch8/clock1
package main
import (
"io"
"log"
"net"
"time"
)
func main() {
listener, err := net.Listen("tcp", "localhost:8082")
if err != nil {
log.Fatal(err)
}
for {
conn, err := listener.Accept()
if err != nil {
log.Print(err) // 例如:连接中止
continue
}
handleConn(conn) // 一次处理一个连接
}
}
func handleConn(c net.Conn) {
defer c.Close()
for {
_, err := io.WriteString(c, time.Now().Format("15:04:05\n"))
if err != nil {
return //例如:连接断开
}
time.Sleep(1 * time.Second)
}
}
Listen函数创建了一个net.Listener的对象, 这个对象会监听一个网络端口上到来的连接, 在这个例子里我们用的是TCP的localhost:8000端口。 listener对象的Accept方法会直接阻塞, 直到一个新的连接被创建, 然后会返回一个net.Conn对象来表示这个连接。
handleConn函数会处理一个完整的客户端连接。 在一个for死循环中, 将当前的时候用time.Now()函数得到, 然后写到客户端。 由于net.Conn实现了io.Writer接口, 我们可以直接向其写入内容。 这个死循环会一直执行, 直到写入失败。 最可能的原因是客户端主动断开连接。 这种情况下handleConn函数会用defer调用关闭服务器侧的连接, 然后返回到主函数, 继续等待下一个连接请求。
time.Time.Format方法提供了一种格式化日期和时间信息的方式。 它的参数是一个格式化模板标识如何来格式化时间, 而这个格式化模板限定为Mon Jan 2 03:04:05PM 2006 UTC-0700。有8个部分(周几, 月份, 一个月的第几天, 等等)。 可以以任意的形式来组合前面这个模板;出现在模板中的部分会作为参考来对时间格式进行输出。 在上面的例子中我们只用到了小时、 分钟和秒。 time包里定义了很多标准时间格式, 比如time.RFC1123。 在进行格式化的逆向操作time.Parse时, 也会用到同样的策略。 (译注: 这是go语言和其它语言相比比较奇葩的一个地方。 。 你需要记住格式化字符串是1月2日下午3点4分5秒零六年UTC-0700, 而不像其它语言那样Y-m-d H:i:s一样, 当然了这里可以用1234567的方式来记忆, 倒是也不麻烦)
为了连接例子里的服务器, 我们需要一个客户端程序, 比如netcat这个工具(nc命令), 这个工具可以用来执行网络连接操作。
客户端将服务器发来的时间显示了出来, 我们用Control+C来中断客户端的执行, 在Unix系统上, 你会看到^C这样的响应。 如果你的系统没有装nc这个工具, 你可以用telnet来实现同样的效果, 或者也可以用我们下面的这个用go写的简单的telnet程序, 用net.Dial就可以简单地创建一个TCP连接:
这个程序会从连接中读取数据, 并将读到的内容写到标准输出中, 直到遇到end of file的条件或者发生错误。 mustCopy这个函数我们在本节的几个例子中都会用到。 让我们同时运行两个客户端来进行一个测试, 这里可以开两个终端窗口, 下面左边的是其中的一个的输出, 右边的是另一个的输出:
killall命令是一个Unix命令行工具, 可以用给定的进程名来杀掉所有名字匹配的进程。
第二个客户端必须等待第一个客户端完成工作, 这样服务端才能继续向后执行; 因为我们这里的服务器程序同一时间只能处理一个客户端连接。 我们这里对服务端程序做一点小改动,使其支持并发: 在handleConn函数调用的地方增加go关键字, 让每一次handleConn的调用都进入一个独立的goroutine。
本节中, 我们会探索一些用来在并行时循环迭代的常见并发模型。 我们会探究从全尺寸图片生成一些缩略图的问题。 gopl.io/ch8/thumbnail包提供了ImageFile函数来帮我们拉伸图片。 我们不会说明这个函数的实现, 只需要从gopl.io下载它。
下面的程序会循环迭代一些图片文件名, 并为每一张图片生成一个缩略图:
显然我们处理文件的顺序无关紧要, 因为每一个图片的拉伸操作和其它图片的处理操作都是彼此独立的。 像这种子问题都是完全彼此独立的问题被叫做易并行问题(译注:embarrassingly parallel, 直译的话更像是尴尬并行)。 易并行问题是最容易被实现成并行的一类问题(废话), 并且是最能够享受并发带来的好处, 能够随着并行的规模线性地扩展。
下面让我们并行地执行这些操作, 从而将文件IO的延迟隐藏掉, 并用上多核cpu的计算能力来拉伸图像。 我们的第一个并发程序只是使用了一个go关键字。 这里我们先忽略掉错误, 之后再进行处理。
这个版本运行的实在有点太快, 实际上, 由于它比最早的版本使用的时间要短得多, 即使当文件名的slice中只包含有一个元素。 这就有点奇怪了, 如果程序没有并发执行的话, 那为什么一个并发的版本还是要快呢? 答案其实是makeThumbnails在它还没有完成工作之前就已经返回了。 它启动了所有的goroutine, 没一个文件名对应一个, 但没有等待它们一直到执行完毕。
没有什么直接的办法能够等待goroutine完成, 但是我们可以改变goroutine里的代码让其能够将完成情况报告给外部的goroutine知晓, 使用的方式是向一个共享的channel中发送事件。 因为我们已经知道内部的goroutine只有len(filenames), 所以外部的goroutine只需要在返回之前对这些事件计数。
回忆一下之前在5.6.1节中, 匿名函数中的循环变量快照问题。 上面这个单独的变量f是被所有的匿名函数值所共享, 且会被连续的循环迭代所更新的。 当新的goroutine开始执行字面函数时, for循环可能已经更新了f并且开始了另一轮的迭代或者(更有可能的)已经结束了整个循环, 所以当这些goroutine开始读取f的值时, 它们所看到的值已经是slice的最后一个元素了。显式地添加这个参数, 我们能够确保使用的f是当go语句执行时的“当前”那个f。
如果我们想要从每一个worker goroutine往主goroutine中返回值时该怎么办呢? 当我们调用thumbnail.ImageFile创建文件失败的时候, 它会返回一个错误。 下一个版本的makeThumbnails会返回其在做拉伸操作时接收到的第一个错误:
这个程序有一个微秒的bug。 当它遇到第一个非nil的error时会直接将error返回到调用方, 使得没有一个goroutine去排空errors channel。 这样剩下的worker goroutine在向这个channel中发送值时, 都会永远地阻塞下去, 并且永远都不会退出。 这种情况叫做goroutine泄露(§8.4.4),可能会导致整个程序卡住或者跑出out of memory的错误。
最简单的解决办法就是用一个具有合适大小的buffered channel, 这样这些worker goroutine向channel中发送测向时就不会被阻塞。 (一个可选的解决办法是创建一个另外的goroutine, 当main goroutine返回第一个错误的同时去排空channel)
下一个版本的makeThumbnails使用了一个buffered channel来返回生成的图片文件的名字,附带生成时的错误。
我们最后一个版本的makeThumbnails返回了新文件们的大小总计数(bytes)。 和前面的版本都不一样的一点是我们在这个版本里没有把文件名放在slice里, 而是通过一个string的channel传过来, 所以我们无法对循环的次数进行预测。
为了知道最后一个goroutine什么时候结束(最后一个结束并不一定是最后一个开始), 我们需要一个递增的计数器, 在每一个goroutine启动时加一, 在goroutine退出时减一。 这需要一种特殊的计数器, 这个计数器需要在多个goroutine操作时做到安全并且提供提供在其减为零之前一直等待的一种方法。 这种计数类型被称为sync.WaitGroup, 下面的代码就用到了这种方法:
// makeThumbnails6 为从通道接收到的每个文件生成缩略图
// 它返回其生成的文件占用的字节数
func makeThumbnails6(filenames <-chan string) int64 {
sizes := make(chan int64)
var wg sync.WaitGroup
for f := range filenames {
wg.Add(1)
go func(f string) {
defer wg.Done()
thumb, err := thumbnail.ImageFile(f)
if err != nil {
log.Println(err)
return
}
info, _ := os.Stat(thumb)
sizes <- info.Size()
}(f)
}
go func() {
wg.Wait()
close(sizes)
}()
var total int64
for size := range sizes {
total += size
}
return total
}
注意Add和Done方法的不对称。 Add是为计数器加一, 必须在worker goroutine开始之前调用, 而不是在goroutine中; 否则的话我们没办法确定Add是在"closer" goroutine调用Wait之前被调用。 并且Add还有一个参数, 但Done却没有任何参数; 其实它和Add(-1)是等价的。 我们使用defer来确保计数器即使是在出错的情况下依然能够正确地被减掉。 上面的程序代码结构是当我们使用并发循环, 但又不知道迭代次数时很通常而且很地道的写法。
sizes channel携带了每一个文件的大小到main goroutine, 在main goroutine中使用了rangeloop来计算总和。 观察一下我们是怎样创建一个closer goroutine, 并让其等待worker们在关闭掉sizes channel之前退出的。 两步操作: wait和close, 必须是基于sizes的循环的并发。 考虑一下另一种方案: 如果等待操作被放在了main goroutine中, 在循环之前, 这样的话就永远都不会结束了, 如果在循环之后, 那么又变成了不可达的部分, 因为没有任何东西去关闭这个channel, 这个循环就永远都不会终止。
图8.5 表明了makethumbnails6函数中事件的序列。 纵列表示goroutine。 窄线段代表sleep,粗线段代表活动。 斜线箭头代表用来同步两个goroutine的事件。 时间向下流动。 注意maingoroutine是如何大部分的时间被唤醒执行其range循环, 等待worker发送值或者closer来关闭channel的。
在本小节中, 我们会创建一个程序来生成指定目录的硬盘使用情况报告, 这个程序和Unix里的du工具比较相似。 大多数工作用下面这个walkDir函数来完成, 这个函数使用dirents函数来枚举一个目录下的所有入口。
gopl.io/ch8/du1
ioutil.ReadDir函数会返回一个os.FileInfo类型的slice, os.FileInfo类型也是os.Stat这个函数的返回值。 对每一个子目录而言, walkDir会递归地调用其自身, 并且会对每一个文件也递归调用。 walkDir函数会向fileSizes这个channel发送一条消息。 这条消息包含了文件的字节大小。
下面的主函数, 用了两个goroutine。 后台的goroutine调用walkDir来遍历命令行给出的每一个路径并最终关闭fileSizes这个channel。 主goroutine会对其从channel中接收到的文件大小进行累加, 并输出其和。
import (
"flag"
"fmt"
"io/ioutil"
"os"
"path/filepath"
)
func main() {
// 确定初始目录
flag.Parse()
roots := flag.Args()
if len(roots) == 0 {
roots = []string{"."}
}
// 遍历文件树
fileSizes := make(chan int64)
go func() {
for _, root := range roots {
walkDir(root, fileSizes)
}
close(fileSizes)
}()
//输出结果
var nfiles, nbytes int64
for size := range fileSizes {
nfiles++
nbytes += size
}
printDiskUsage(nfiles, nbytes)
}
如果在运行的时候能够让我们知道处理进度的话想必更好。 但是, 如果简单地把printDiskUsage函数调用移动到循环里会导致其打印出成百上千的输出。
下面这个du的变种会间歇打印内容, 不过只有在调用时提供了-v的flag才会显示程序进度信息。 在roots目录上循环的后台goroutine在这里保持不变。 主goroutine现在使用了计时器来每500ms生成事件, 然后用select语句来等待文件大小的消息来更新总大小数据, 或者一个计时器的事件来打印当前的总大小数据。 如果-v的flag在运行时没有传入的话, tick这个channel会保持为nil, 这样在select里的case也就相当于被禁用了。
由于我们的程序不再使用range循环, 第一个select的case必须显式地判断fileSizes的channel是不是已经被关闭了, 这里可以用到channel接收的二值形式。 如果channel已经被关闭了的话, 程序会直接退出循环。 这里的break语句用到了标签break, 这样可以同时终结select和for两个循环; 如果没有用标签就break的话只会退出内层的select循环, 而外层的for循环会使之进入下一轮select循环。
然而这个程序还是会花上很长时间才会结束。 无法对walkDir做并行化处理没什么别的原因,无非是因为磁盘系统并行限制。 下面这个第三个版本的du, 会对每一个walkDir的调用创建一个新的goroutine。 它使用sync.WaitGroup (§8.5)来对仍旧活跃的walkDir调用进行计数, 另一个goroutine会在计数器减为零的时候将fileSizes这个channel关闭。
由于这个程序在高峰期会创建成百上千的goroutine, 我们需要修改dirents函数, 用计数信号量来阻止他同时打开太多的文件, 就像我们在8.7节中的并发爬虫一样:
这个版本比之前那个快了好几倍, 尽管其具体效率还是和你的运行环境, 机器配置相关。