go语言从零学起(三) -- chat实现的思考

要通过go实现一个应用场景:

1 建立一个websocket服务

2 维护在线用户的链接

3 推送消息和接受用户的操作

列出需求,很显然的想到了chat模型。于是研究了revel框架提供的samples/chat代码,以及基于gorilla/websocket实现的chat。

他们实现的思路比较类似,大概代码如下:

package main

import (
    "github.com/go-martini/martini"
    "github.com/gorilla/websocket"
    "net/http"
    "time"
)

const (
    readBufferSize  = 1024
    writeBufferSize = 1024
)

type Client struct {
    conn *websocket.Conn
}

// 常住监听一个链接的读
func (c *Client) readPump() {

    for {

        mstype, message, err := c.conn.ReadMessage()

        // .......   该处处理接受用户发来的数据
    }
}
//监听一个链接的写
func (c *Client) writePump() {

    for {
        c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))

        // ....... 该处处理推送给用户的数据
    }
}

//  基于martini实现的websocket服务
func main() {
    m := martini.Classic()
    m.Get("/ws", func(res http.ResponseWriter, req *http.Request) {
        conn, err := websocket.Upgrade(res, req, nil, readBufferSize, writeBufferSize)
        if err != nil {
            log.Println(err)
            return
        }
        client := &Client{conn: conn}

        go client.writePump() //建立一个goroutine

        client.readPump()

    })
    m.Run()
}

上述代码只是用来简述运用go语言实现chat的大概思路,在最后会贴出原代码的出处。

通过看上面代码会发现,每个链接进来就需要2个goroutine,去维护它的读跟写。如果要是1万个链接,相应的就要有2万个常驻而不会释放的goroutine去监听。这样的话,到最后会不会只是维护链接就会耗尽服务器的cpu跟内存。因为只是接触go的时间不长,看完代码我有些质疑这样实现的效率问题。为了佐证我的想法,我查了下c基于epoll对链接读写的维护,代码如下:

struct epoll_event ev, *events;
for(;;) {
 nfds = epoll_wait(kdpfd, events, maxevents, -1); //等待I/O事件
 for(n = 0; n < nfds; ++n) {
  if(events[n].data.fd == listener) { //如果是主socket的事件,则表示有新连接进入,需要进行新连接的处理。
    client = accept(listener, (struct sockaddr *) &local,  &addrlen);
    if(client < 0){
      perror("accept error");
      continue;
    }
    setnonblocking(client); // 将新连接置于非阻塞模式
    ev.events = EPOLLIN | EPOLLET; 
                                   //注意这里的参数EPOLLIN | EPOLLET并没有设置对写socket的监听,
                                   //如果有写操作的话,这个时候epoll是不会返回事件的,
                                   //如果要对写操作也监听的话,应该是EPOLLIN | EPOLLOUT | EPOLLET。
    ev.data.fd = client; // 并且将新连接也加入EPOLL的监听队列
    if (epoll_ctl(kdpfd, EPOLL_CTL_ADD, client, &ev) < 0) {  // 设置好event之后,将这个新的event通过epoll_ctl
                                                             //加入到epoll的监听队列里,这里用EPOLL_CTL_ADD
                                                             //来加一个新的 epoll事件。可以通过EPOLL_CTL_DEL来减少
                                                             //一个epoll事件,通过EPOLL_CTL_MOD来改变一个事件的监听方式。
      fprintf(stderr, "epoll set insertion error: fd=%d"0, client);
      return -1;
    }
  }  else // 如果不是主socket的事件的话,则代表这是一个用户的socket的事件,
            // 则用来处理这个用户的socket的事情是,比如说read(fd,xxx)之类,或者一些其他的处理。
    do_use_fd(events[n].data.fd);
 }
}

从代码层面对比来看,同是1万个链接,c基于epoll的实现只有一个常驻的线程去监听I/O事件,而go需要2万个goroutine去监听。这2种语言实现方式间会不会存在比较明显的性能差距,这是我开始学习的时候,一直存在的疑问。

不过看了这篇文章时,解除了我很多疑惑,也让我对go的goroutine有了深入一点的认识。

https://tiancaiamao.gitbooks.io/go-internals/content/zh/05.2.html (goroutine的生老病死)

1 goroutine切换时的上下文信息是保存在结构体的sched域中的。goroutine是轻量级的“线程”或者称为协程,切换时变不必陷入到操作系统内核中,所以保存过程很轻量

2 goroutine创建完成完后,会放入相应的队列

3 go会建多个worker进程,worker做的事情就是不停的去任务队列中取一个任务出来执行

4 goroutine准备好的时候,会获取cpu去执行,执行完了会让出cpu然后挂起

模式大概是这样的:

func M() {
    for {
        sched.lock.Lock()    //互斥地从就绪G队列中取一个g出来运行
        if sched.allg > 0 {
            g := sched.allg[0]
            sched.allg = sched.allg[1:]
            sched.lock.Unlock()
            g.Run()        //运行它
        } else {
            sched.lock.Unlock()
        }
    }
}
for i:=0; i<GOMAXPROCS; i++ {
    go M()
}

上面这些是刚开始学习的一些疑惑和感悟,希望之后深入学习后,对go有更全面的认识。

参考资料:

1 https://tiancaiamao.gitbooks.io/go-internals/content/zh/05.2.html

2 https://tiancaiamao.gitbooks.io/go-internals/content/zh/05.3.html

3 http://leejia.blog.51cto.com/4356849/1021066

revel的chat代码

https://github.com/revel/samples

gorilla/websocket的chat代码

https://github.com/gorilla/websocket/tree/master/examples