原文地址:http://tchen.me/posts/2014-01-27-golang-chatroom.html?utm_source=tuicool&utm_medium=referral 看了一上午写得很好,可以拿来试试刀

最近在team内部培训golang,目标是看看golang能否被C工程师快速掌握。我定了个一个月,共计20小时的培训计划,首先花10个小时(两周,每天1小时)让大家掌握golang的基本要素,能写一些入门级的程序,之后再花两周时间做一个1000行代码规模的Proof of concept项目。为了能在培训的slides上直接运行go code,我做了个简单的 coderunnerd,可以接受websocket传过来的code,编译运行再把stdout返回给websocket,为了更清晰地说明goroutine和chan的使用,以及golang的一些best practice,我分阶段写了个 chatroom。本文介绍一下如何使用goroutine和chan来做一个简单的聊天室。

需求

聊天室的需求很简单:

  • 服务器监听某个端口,客户端可连接并开始聊天。
  • 任何客户端的发言都会被广播给所有客户端。
  • 客户端可以为自己设定名字或者执行一些聊天命令。

设计与实现

基本想法

服务器(Server):

  • Server accept下来的connection被存在一个数据结构Client中,并以connection为key,Client为value,存在map里。
  • 每个Client都有自己的goroutine去接受和发送消息。Client和Server之间通过channel来传递消息。

客户端(Client):

  • 发送和接收都有各自的goroutine,通过channel和stdin/stdout交互

实现

所有chat相关的逻辑都被封装在 chat package里,client和server的cli只负责将ui和chat粘合起来。

首先,是核心的数据结构:

type Message chan string

type Client struct {
conn net.Conn
incoming Message
outgoing Message
reader *bufio.Reader
writer *bufio.Writer
quiting chan net.Conn
name string
}

Client 是一个服务器和客户端都共享的数据结构。conn是建立的连接,reader/writer是conn上的bufio。Client与外界的接口是incoming/outgoing两个channel,即:Server 会把要发送的内容 push 到 outgoing channel 里,供writer去写;而从reader读入的数据会 push 到 incoming channel 里,供 Server 读。

每个 Client 有自己的名字,服务器端代码会使用这个名字(客户端代码不会使用)。

type Token chan int
type ClientTable map[net.Conn]*Client type Server struct {
listener net.Listener
clients ClientTable
tokens Token
pending chan net.Conn
quiting chan net.Conn
incoming Message
outgoing Message
}

Server 保存一张 ClientTable。每个 accept 到的 conn 会 push 进 pending channel,等待创建client。Server有 incoming / outgoing 两个 channel,分别和 client 的 incoming / outgoing 关联。

Server 有一组 tokens,决定了一个Server最多能装多少Client(避免Server overloading)。

下面看 Server 的创建流程:

const (
MAXCLIENTS = 50
) func CreateServer() *Server {
server := &Server{
clients: make(ClientTable, MAXCLIENTS),
tokens: make(Token, MAXCLIENTS),
pending: make(chan net.Conn),
quiting: make(chan net.Conn),
incoming: make(Message),
outgoing: make(Message),
}
server.listen()
return server
}

很简单,无须多说。server.Listen() 实现如下:

func (self *Server) listen() {
go func() {
for {
select {
case message := <-self.incoming:
self.broadcast(message)
case conn := <-self.pending:
self.join(conn)
case conn := <-self.quiting:
self.leave(conn)
}
}
}()
}

这是一个 goroutine,做三件事:

  • 如果 self.incoming 收到东西,将其 broadcast 出去。
  • 如果有新的连接,则将其接入到聊天室。
  • 如果一个 Client 退出,则进行一些清理和通知。

我们先看一个新连接如何加入到聊天室:

func (self *Server) join(conn net.Conn) {
client := CreateClient(conn)
name := getUniqName()
client.SetName(name)
self.clients[conn] = client log.Printf("Auto assigned name for conn %p: %s\n", conn, name) go func() {
for {
msg := <-client.incoming
log.Printf("Got message: %s from client %s\n", msg, client.GetName()) if strings.HasPrefix(msg, ":") {
if cmd, err := parseCommand(msg); err == nil {
if err = self.executeCommand(client, cmd); err == nil {
continue
} else {
log.Println(err.Error())
}
} else {
log.Println(err.Error())
}
}
// fallthrough to normal message if it is not parsable or executable
self.incoming <- fmt.Sprintf("%s says: %s", client.GetName(), msg)
}
}() go func() {
for {
conn := <-client.quiting
log.Printf("Client %s is quiting\n", client.GetName())
self.quiting <- conn
}
}()
}

这里先通过连接建立 Client 数据,为其自动分配一个唯一的名字,然后将其加入到 ClientTable 中。注意在这个函数里每个 Client 会运行两个 goroutine,我们先记住这一点。

第一个 goroutine 从 Client 的 incoming channel 中拿出 message,如果是命令的话就执行之,否则将其放入 Server 的 incoming channel,等待被 broadcast 出去。之前 Listen() 方法里有对应的处理:

            case message := <-self.incoming:
self.broadcast(message)

顺手看一下 broadcast 怎么做的:

func (self *Server) broadcast(message string) {
log.Printf("Broadcasting message: %s\n", message)
for _, client := range self.clients {
client.outgoing <- message
}
}

第二个 goroutine 从 Client 的 quiting channel 中拿出 conn,放入 Server 的 quiting channel 中,等待处理某个 Client 的退出。同样在 Listen() 中有处理:

            case conn := <-self.quiting:
self.leave(conn)

顺手也看看 Leave 做些什么:

func (self *Server) leave(conn net.Conn) {
if conn != nil {
conn.Close()
delete(self.clients, conn)
} self.generateToken()
}

Leave 里有两个坑,一个是从 map 里删除一个 key 是否需要 synchronize,我们放在下面的『并发与同步』里详细再表;另一个坑是 generateToken(),马上就会讲到。

看了这么多代码了,还没看到服务器建连的代码,有点说不过去。接下来我们看 Start

func (self *Server) Start(connString string) {
self.listener, _ = net.Listen("tcp", connString) log.Printf("Server %p starts\n", self) // filling the tokens
for i := 0; i < MAXCLIENTS; i++ {
self.generateToken()
} for {
conn, err := self.listener.Accept() if err != nil {
log.Println(err)
return
} log.Printf("A new connection %v kicks\n", conn) self.takeToken()
self.pending <- conn
}
}

这里 generateToken 及 takeToken 与 Leave 里的 generateToken 呼应。这些代码对应一个隐式需求:服务器不可过载。所以我们有 MAXCLIENTS 来限制一个服务器的 client 上限。但是,怎么比较漂亮地处理这个上限问题?因为在一个真实的聊天场景下,聊天室里的人是可以进进出出的。

我们采用 token。系统生成有限的 token,被拿光后,当且仅当有人归还 token,等待者才能获得 token,进入聊天室。在 golang 中,goroutine 和 chan 简直是为此需求量身定制的。我们看运作机制:

  • 首先生成 MAXCLIENTS 个 token。
  • 第 1 - MAXCLIENTS 个 client:
    • 从 tokens 里拿走一个 token
    • 把自己的 conn 放入 pending channel(如果之前的 pending conn 还被取走,则这个 goroutine就会被挂起,等待之前的 pending conn 被取走。否则,继续执行。
  • 第 (MAXCLIENTS + 1) 个 client:
    • 从 tokens 里拿不到 token 了,当前的 goroutine 在这一点上挂起,等待 token。
  • 有人离开:
    • 归还一个 token,这样之前被挂起等待 token 的 goroutine 被唤醒,继续执行。

没有使用任何同步机制,代码干净清晰漂亮,我们就完成了一个排队系统。Ura for go!


喘一口气,接下来看 join 的时候调用的 CreateClient 的代码:

func CreateClient(conn net.Conn) *Client {
reader := bufio.NewReader(conn)
writer := bufio.NewWriter(conn) client := &Client{
conn: conn,
incoming: make(Message),
outgoing: make(Message),
quiting: make(chan net.Conn),
reader: reader,
writer: writer,
}
client.Listen()
return client
}

client.Listen 极其细节:

func (self *Client) Listen() {
go self.Read()
go self.Write()
} func (self *Client) Read() {
for {
if line, _, err := self.reader.ReadLine(); err == nil {
self.incoming <- string(line)
} else {
log.Printf("Read error: %s\n", err)
self.quit()
return
}
} } func (self *Client) Write() {
for data := range self.outgoing {
if _, err := self.writer.WriteString(data + "\n"); err != nil {
self.quit()
return
} if err := self.writer.Flush(); err != nil {
log.Printf("Write error: %s\n", err)
self.quit()
return
}
} }

client.Listen 里我们也生成了两个 goroutine,加上之前的两个,每个 client 有四个 goroutine(所以运行中的Server的 gorutine 的数量接近于 client num * 4)。虽然我们可以做一些优化,但这并不要紧,一个 go 进程里运行成千上万个 goroutine没有太大问题,因为 goroutine 运行在 userspace,其 memory footprint很小(几k),切换代价非常低(没有 syscall)。

这两个 goroutine 正如一开始设计时提到的,一读一写,通过 channel 和外界交互。

这就是整个聊天室的主体代码。接下来的命令行就很简单了。

先看 Server 代码:

package main

import (
. "chatroom/chat"
"fmt"
"os"
) func main() {
if len(os.Args) != 2 {
fmt.Printf("Usage: %s <port>\n", os.Args[0])
os.Exit(-1)
} server := CreateServer()
fmt.Printf("Running on %s\n", os.Args[1])
server.Start(os.Args[1]) }

接下来是 Client 代码:

package main

import (
"bufio"
. "chatroom/chat"
"fmt"
"log"
"net"
"os"
) func main() {
if len(os.Args) != 2 {
fmt.Printf("Usage: %s <port>\n", os.Args[0])
os.Exit(-1)
} conn, err := net.Dial("tcp", os.Args[1]) if err != nil {
log.Fatal(err)
} defer conn.Close()
in := bufio.NewReader(os.Stdin)
out := bufio.NewWriter(os.Stdout) client := CreateClient(conn) go func() {
for {
out.WriteString(client.GetIncoming() + "\n")
out.Flush()
}
}() for {
line, _, _ := in.ReadLine()
client.PutOutgoing(string(line))
} }

运行一下(起了两个client):

➜  chatroom git:(master) ./bin/chatserver :5555
➜ chatroom git:(master) ./bin/chatserver :5555
Running on :5555
2014/01/30 09:05:24 Server 0xc2000723c0 starts
2014/01/30 09:05:34 A new connection &{{0xc20008f090}} kicks
2014/01/30 09:05:34 Auto assigned name for conn 0xc200000100: User 0
2014/01/30 09:05:48 A new connection &{{0xc20008f120}} kicks
2014/01/30 09:05:48 Auto assigned name for conn 0xc200000148: User 1
2014/01/30 09:06:39 Got message: Hello from client User 0
2014/01/30 09:06:39 Broadcasting message: User 0 says: Hello
2014/01/30 09:06:48 Got message: :name Tyr from client User 1
2014/01/30 09:06:48 Broadcasting message: Notification: User 1 changed its name to Tyr
2014/01/30 09:06:57 Got message: Hello world! from client User 0
2014/01/30 09:06:57 Broadcasting message: User 0 says: Hello world!
2014/01/30 09:07:01 Got message: Hello from client Tyr
2014/01/30 09:07:01 Broadcasting message: Tyr says: Hello
2014/01/30 09:08:19 Read error: EOF
2014/01/30 09:08:19 Client User 0 is quiting
2014/01/30 09:08:19 Broadcasting message: Notification: User 0 quit the chat room.

其中一个 client:

➜  chatroom git:(master) ./bin/chatclient :5555
User 0 says: Hello
:name Tyr
Notification: User 1 changed its name to Tyr
User 0 says: Hello world!
Hello
Tyr says: Hello
Notification: User 0 quit the chat room.

完整代码请见 github repo

以上代码能正确运行,不过还有不少问题,比如 server stop 时 goroutine 并未正确 cleanup。但对于理解 goroutine 和 chan 来说,不失为一个很好的例子。

Lessons learnt

使用go test

我现在写代码已经离不开非常方便的 go test 了。golang 的开发者们非常聪明,他们知道把一个 test framework / utility 放在核心的安装包中是多么重要。这个 chatroom 是迭代开发的,你可以 checkout v0.1/v0.2/v0.3 分别看不同时期的代码。每次添加新功能,或者重构代码时,go test ./chat 就是我信心的保证。代码和test case同步开发,新的 feature 有新的 case 去 cover,这样一点点做上去。拿柳总的话说,就是:『垒一层土,夯实,再垒一层』。

例子:

➜  chatroom git:(master) go test ./chat
ok chatroom/chat 0.246s

并发与同步

golang 在设计时做了很多取舍。其中,对map的操作是否原子就有很多 debate。最终,为了 performance,map 的操作不具备原子性,亦即不是 multithread safe。所以,正确的做法是在从 map 中删除一个 conn 时和使用 range 中读取时做读写同步。由于本例运行在单线程环境下(是的,如果你不指定,golang process 默认单线程),且以教学为目的,实在不忍用难看的同步操作降低代码的美感。

另外一种做法是在读写两个需要同步的地方使用 channel 进行同步(还记得刚刚讲的 token)吧?

如果你对 map 的 thread-safe 感兴趣,可以读读 stackoverflow上的这个问题

通过close来向所有goroutine传递终止讯息

在我的代码里,close 做得比较 ugly,不知你是否感受到了。更好的做法是使用 close 一个 channel 来完成关闭 goroutine 的动作。当 close 发生时,所有接收这个 channel 的 goroutine 都会收到通知。下面是个简单的例子:

package main

import (
"fmt"
"strconv"
"time"
) const (
N = 10
) func main() {
quit := make(chan bool) for i := 0; i < N; i++ {
go func(name string) {
for {
select {
case <-quit:
fmt.Printf("clean up %s\n", name)
return
}
}
}(strconv.Itoa(i))
}
close(quit) for {
time.Sleep(1 * time.Second)
}
}

我生成了 N 个 goroutine,但只需使用一个 close 就可以将其全部关闭。在 chatroom 代码中,关闭 server 时,也可以采用相同的方法,关闭所有的 client 上的 goroutine。

下面是上述代码执行的结果:

➜  terminate  go run terminate.go
clean up 0
clean up 1
clean up 2
clean up 3
clean up 4
clean up 5
clean up 6
clean up 7
clean up 8
clean up 9

尽可能把任务分布在goroutine中

如果你没有看过 Rob Pike 的 Concurrency is not parallelism,建议一定要看,不管你有没有 golang 的 background。Concurrency 是你写软件的一种追求,和是否并行无关,但和模块化,简单,优雅有关。

goroutine不可做无阻塞的infinite loop

goroutine,至少在 golang 1.2 及之前的版本,都运行在一个 cooperative multitasking 的 scheduler 上。所以你要保证你的任何一个 infinite loop 都要有可能被 block 住,无论是 block 在 IO, chan, 还是主动 block 在 timer 上,总之,infinite loop 要有退出机制。刚才的例子我们稍微改改:

package main

import (
"fmt"
"strconv"
//"time"
) const (
N = 10
) func main() {
quit := make(chan bool) for i := 0; i < N; i++ {
go func(name string) {
for {
select {
case <-quit:
fmt.Printf("clean up %s\n", name)
return
}
}
}(strconv.Itoa(i))
}
close(quit) for {
//time.Sleep(1 * time.Second)
}
}

乍一看,这个例子中的 gorountine应该能收到 close 而自我关闭。在 main 执行的过程中,头十个新创建出来的 goroutine 还未得到调度。虽然在 main 里我们 close 了 quit,但由于接下来的 dead loop 一直不释放 CPU,所以其他 goroutine 一直得不到调度。运行的话没有任何输出:

➜  terminate  go run terminate.go
^Cexit status 2

我们稍稍改改这个程序:

package main

import (
"fmt"
"runtime"
"strconv"
//"time"
) const (
N = 10
) func main() {
runtime.GOMAXPROCS(2)
quit := make(chan bool) for i := 0; i < N; i++ {
go func(name string) {
for {
select {
case <-quit:
fmt.Printf("clean up %s\n", name)
return
}
}
}(strconv.Itoa(i))
}
close(quit) for {
//time.Sleep(1 * time.Second)
}
}

现在允许这个程序运行在两个 thread 上。这样就能正常运行了。但切记,没有阻塞机制的 infinite loop 不是一个好的设计。

➜  terminate  go run terminate1.go
clean up 0
clean up 1
clean up 2
clean up 3
clean up 4
clean up 5
clean up 6
clean up 7
clean up 8
clean up 9
^Cexit status 2

DRY (Don't Repeat Yourself)

写 chatroom 时,我不断重构代码,其目的就是能让代码干净,漂亮。比方我的一次 commit:git diff 39690d9 6851177,就是在做 test case refactor。

DRY 的前提是有完善的 test case,前文也提到。这是项目内部的 DRY。

另外一种 DRY 的方式是(从我途客圈的前同事 @chenchiyuan 那里学到的):如果两个或以上的项目中都用到类似结构的代码,则考虑将其重构到一个第三方的 lib 里。在 chatroom 中,有两处这样的重构,重构在我的 goutil 项目中。

第一处是生成唯一数:

package uniq

var (
num = make(chan int)
) func init() {
go func() {
for i := 0; ; i++ {
num <- i
}
}()
} func GetUniq() int {
return <-num
}

第二处是正则表达式匹配,将匹配的结果放入一个 map 的 slice 里:

package regex

import (
"regexp"
) const (
KVPAIR_CAP = 16
) type KVPair map[string]string func MatchAll(r *regexp.Regexp, data string) (captures []KVPair, ok bool) {
captures = make([]KVPair, 0, KVPAIR_CAP)
names := r.SubexpNames()
length := len(names)
matches := r.FindAllStringSubmatch(data, -1)
for _, match := range matches {
cmap := make(KVPair, length)
for pos, val := range match {
name := names[pos]
if name != "" {
cmap[name] = val
}
}
captures = append(captures, cmap)
}
if len(captures) > 0 {
ok = true
}
return
}

总结一条铁律:project 级的 DRY 是函数化,package化;cross project的 DRY 是 repo 化。

Golang之chan/goroutine(转)的更多相关文章

  1. Golang 探索对Goroutine的控制方法

    前言 在golang中,只需要在函数调用前加上关键字go即可创建一个并发任务单元,而这个新建的任务会被放入队列中,等待调度器安排.相比系统的MB级别线程栈,goroutine的自定义栈只有2KB,这使 ...

  2. golang并发编程goroutine+channel(一)

    go语言的设计初衷除了在不影响程序性能的情况下减少复杂度,另一个目的是在当今互联网大量运算下,如何让程序的并发性能和代码可读性达到极致.go语言的并发关键词 "go" go dos ...

  3. Golang 入门 : 等待 goroutine 完成任务

    Goroutine 是 Golang 中非常有用的功能,但是在使用中我们经常碰到下面的场景:如果希望等待当前的 goroutine 执行完成,然后再接着往下执行,该怎么办?本文尝试介绍这类问题的解决方 ...

  4. golang中的goroutine

    1. 概念 go中可以并发执行的活动单元称为goroutine当一个go程序启动时,一个执行main function的goroutine会被创建,称为main goroutinego func() ...

  5. golang使用chan注意事项

    背景 最近老代码中遇到的一个问题,表现为: goroutine数量在高峰期上涨,上涨后平峰期将不下来.也就是goroutine泄露 使用pprof看,进程堵塞在chan chan的使用经验 在使用ch ...

  6. golang学习笔记 --- goroutine

    package main import ( "fmt" "io" "io/ioutil" "net/http" &quo ...

  7. Golang教程:goroutine信道

    在上一篇教程中,我们讨论了如何使用协程实现并发.在这篇教程中,我们将讨论信道以及如何使用信道实现协程间通信. 什么是信道 信道(Channel)可以被认为是协程之间通信的管道.与水流从管道的一端流向另 ...

  8. Golang并发编程——goroutine、channel、sync

    并发与并行 并发和并行是有区别的,并发不等于并行. 并发 两个或多个事件在同一时间不同时间间隔发生.对应在Go中,就是指多个 goroutine 在单个CPU上的交替运行. 并行 两个或者多个事件在同 ...

  9. Golang教程:goroutine协程

    在上一篇中,我们讨论了并发,以及并发和并行的区别.在这篇教程中我们将讨论在Go中如何通过Go协程实现并发. 什么是协程 Go协程(Goroutine)是与其他函数或方法同时运行的函数或方法.可以认为G ...

随机推荐

  1. HTTP Header 详解

    HTTP(HyperTextTransferProtocol)即超文本传输协议,目前网页传输的的通用协议.HTTP协议采用了请求/响应模型,浏览器或其他客户端发出请求,服务器给与响应.就整个网络资源传 ...

  2. sort()基础知识总结+超简短的英文名排序写法

    结合前些天学的箭头函数我想到一种非常简短的sort排序写法:(这可能是最短的英文名排序方法了) 贴出来大家一起探讨一下: [4,1,2,32].sort((x,y)=>x>y); //[1 ...

  3. Python-第三方库requests详解

    Requests 是用Python语言编写,基于 urllib,采用 Apache2 Licensed 开源协议的 HTTP 库.它比 urllib 更加方便,可以节约我们大量的工作,完全满足 HTT ...

  4. Mac地址

    Mac地址是每张网卡的唯一标识符,也叫物理地址.硬件地址或链路地址,由网络设备制造商生产时烧在网卡的ROM中,可以修改.现在的Mac地址一般都采用6字节48bit(还有2字节16bit的Mac地址,多 ...

  5. LeetCode 290 Word Pattern

    Problem: Given a pattern and a string str, find if str follows the same pattern. Here follow means a ...

  6. sed 命令

    使用sed操作: .个人博客的文件,只输出学生姓名 .txt .txt .只输出每个学生的url .txt .只输出个人博客里的学号 .txt .只输出个人博客中,两个字姓名的学生名 .txt .只输 ...

  7. October 26th Week 44th Wednesday 2016

    No matter how far you may fly, never forget where you come from. 无论飞得多高,也不要忘记起飞的地方. I never forget w ...

  8. 分布式之Zookeeper使用

    在zookeeper中可分为单一模式和集群模式. 具体详细的配置与操作,可参见:http://blog.csdn.net/shatelang/article/details/7596007. 单一模式 ...

  9. Servlet引擎Jetty之入门1

    Jetty与tomcat一样,HttpWeb容器,支持实现Servlet规范. 详细介绍参考:https://www.ibm.com/developerworks/cn/java/j-lo-jetty ...

  10. TFS API:一、TFS 体系结构和概念

    TFS API:一.TFS  体系结构和概念 TFS是Team Fundation Server的简称,是微软VSTS的一部分,它是Microsoft应用程序生命周期管理(ALM)工具的核心协作平台, ...