Tao - Go語言實現(xiàn)的TCP網(wǎng)絡(luò)編程框架

一. 什么是Tao

Tao,在英文中的意思是“The ultimate principle of universe”,即“道”,它是宇宙的終極奧義。

“道生一,一生二,二生三,三生無窮。” ——《道德經(jīng)》

Tao同時也是我用Go語言開發(fā)的一個異步的TCP服務(wù)器框架(TCP Asynchronous Go server FramewOrk),秉承Go語言“Less is more”的極簡主義哲學(xué),它能穿透一切表象,帶你一窺網(wǎng)絡(luò)編程的世界,讓你從此徹底擺脫只會寫“socket-bind-listen-accept”的窘境。本文將簡單討論一下這個框架的設(shè)計思路以及自己的一些思考。

1. Tao解決什么問題

1.1 場景

你開發(fā)的產(chǎn)品有一套特有的業(yè)務(wù)邏輯,要通過互聯(lián)網(wǎng)得到服務(wù)端的支持才能為你的客戶提供服務(wù)。

1.2 問題

怎樣快速穩(wěn)定地實現(xiàn)產(chǎn)品的功能,而不需要耗費大量的時間處理各種底層的網(wǎng)絡(luò)通信細節(jié)。

1.3 解決方案

Tao提供了一種用框架支撐業(yè)務(wù)邏輯的機制。你只需要與客戶端定義好消息格式,然后將對應(yīng)的業(yè)務(wù)邏輯編寫成函數(shù)注冊到框架中就可以了。

2. 50行啟動一個聊天服務(wù)器

讓我們舉一個例子來看看如何使用Tao框架實現(xiàn)一個簡單的群聊天服務(wù)器。服務(wù)器端代碼可以這么寫:

package main

import (
    "fmt"
    "net"

    "github.com/leesper/holmes"
    "github.com/leesper/tao"
    "github.com/leesper/tao/examples/chat"
)

// ChatServer is the chatting server.
type ChatServer struct {
    *tao.Server
}

// NewChatServer returns a ChatServer.
func NewChatServer() *ChatServer {
    onConnectOption := tao.OnConnectOption(func(conn tao.WriteCloser) bool {
        holmes.Infoln("on connect")
        return true
    })
    onErrorOption := tao.OnErrorOption(func(conn tao.WriteCloser) {
        holmes.Infoln("on error")
    })
    onCloseOption := tao.OnCloseOption(func(conn tao.WriteCloser) {
        holmes.Infoln("close chat client")
    })
    return &ChatServer{
        tao.NewServer(onConnectOption, onErrorOption, onCloseOption),
    }
}

func main() {
    defer holmes.Start().Stop()

    tao.Register(chat.ChatMessage, chat.DeserializeMessage, chat.ProcessMessage)

    l, err := net.Listen("tcp", fmt.Sprintf("%s:%d", "0.0.0.0", 12345))
    if err != nil {
        holmes.Fatalln("listen error", err)
    }
    chatServer := NewChatServer()
    err = chatServer.Start(l)
    if err != nil {
        holmes.Fatalln("start error", err)
    }
}

啟動一個服務(wù)器只需要三步就能完成。首先注冊消息和業(yè)務(wù)邏輯回調(diào),其次填入IP地址和端口,最后Start一下就可以了。這時候客戶端就能夠發(fā)起連接,并開始聊天。業(yè)務(wù)邏輯的實現(xiàn)很簡單,遍歷所有的連接,然后發(fā)送數(shù)據(jù):

// ProcessMessage handles the Message logic.
func ProcessMessage(ctx context.Context, conn tao.WriteCloser) {
    holmes.Infof("ProcessMessage")
    s, ok := tao.ServerFromContext(ctx)
    if ok {
        msg := tao.MessageFromContext(ctx)
        s.Broadcast(msg)
    }
}

3. Go語言的編程哲學(xué)

Go語言是“云計算時代的C語言”,適用于開發(fā)基礎(chǔ)性服務(wù),比如服務(wù)器。它語法類似C語言且標(biāo)準(zhǔn)庫豐富,上手較快,所以開發(fā)效率高;編譯速度快,運行效率接近C,所以運行效率高。

3.1 面向?qū)ο缶幊?/h3>

Go語言面向?qū)ο缶幊痰娘L(fēng)格是“多用組合,少用繼承”,以匿名嵌入的方式實現(xiàn)繼承。比如上面的聊天服務(wù)器ChatServer:

// ChatServer is the chatting server.
type ChatServer struct {
    *tao.Server
}

于是ChatServer就自動繼承了Server所有的屬性和方法。當(dāng)然,這里是以指針的方式嵌入的。

3.2 面向接口編程

Go語言的面向接口編程是“鴨子類型”的,即“如果我走起來像鴨子,叫起來像鴨子,那么我就是一只鴨子”。其他的編程語言需要顯示地說明自己繼承某個接口,Go語言卻采取的是“隱式聲明”的方式。比如Tao框架使用的多線程日志庫Holmes實現(xiàn)“每小時創(chuàng)建一個新日志文件”功能的核心代碼如下:

func (ls *logSegment)Write(p []byte) (n int, err error) {
  if ls.timeToCreate != nil && ls.logFile != os.Stdout && ls.logFile != os.Stderr {
    select {
    case current := <-ls.timeToCreate:
      ls.logFile.Close()
      ls.logFile = nil
      name := getLogFileName(current)
      ls.logFile, err = os.Create(path.Join(ls.logPath, name))
      if err != nil {
        fmt.Fprintln(os.Stderr, err)
        ls.logFile = os.Stderr
      } else {
        next := current.Truncate(ls.unit).Add(ls.unit)
        ls.timeToCreate = time.After(next.Sub(time.Now()))
      }
    default:
      // do nothing
    }
  }
  return ls.logFile.Write(p)
}

而標(biāo)準(zhǔn)庫中的io.Writer定義如下,那么這里的logSegment就實現(xiàn)了io.Writer的接口,所有以io.Writer作為形參的函數(shù),我都可以傳一個logSegment的實參進去。

type Writer interface {
    Write(p []byte) (n int, err error)
}

3.3 一個中心,兩個基本點

掌握Go語言,要把握“一個中心,兩個基本點”。“一個中心”是Go語言并發(fā)模型,即“不要通過共享內(nèi)存來通信,要通過通信來共享內(nèi)存”;“兩個基本點”是Go語言的并發(fā)模型的兩大基石:channel和go-routine。理解了它們就能看懂大部分代碼。下面讓我們正式開始介紹Tao框架吧。

二. Tao的設(shè)計思路

1. 服務(wù)器的啟動

Tao框架支持通過tao.TLSCredsOption()函數(shù)提供傳輸層安全的TLS Server。服務(wù)器的核心職責(zé)是“監(jiān)聽并接受客戶端連接”。每個進程能夠打開的文件描述符是有限制的,所以它還需要限制最大并發(fā)連接數(shù),關(guān)鍵代碼如下:

// Start starts the TCP server, accepting new clients and creating service
// go-routine for each. The service go-routines read messages and then call
// the registered handlers to handle them. Start returns when failed with fatal
// errors, the listener willl be closed when returned.
func (s *Server) Start(l net.Listener) error {
    s.mu.Lock()
    if s.lis == nil {
        s.mu.Unlock()
        l.Close()
        return ErrServerClosed
    }
    s.lis[l] = true
    s.mu.Unlock()

    defer func() {
        s.mu.Lock()
        if s.lis != nil && s.lis[l] {
            l.Close()
            delete(s.lis, l)
        }
        s.mu.Unlock()
    }()

    holmes.Infof("server start, net %s addr %s\n", l.Addr().Network(), l.Addr().String())

    s.wg.Add(1)
    go s.timeOutLoop()

    var tempDelay time.Duration
    for {
        rawConn, err := l.Accept()
        if err != nil {
            if ne, ok := err.(net.Error); ok && ne.Temporary() {
                if tempDelay == 0 {
                    tempDelay = 5 * time.Millisecond
                } else {
                    tempDelay *= 2
                }
                if max := 1 * time.Second; tempDelay >= max {
                    tempDelay = max
                }
                holmes.Errorf("accept error %v, retrying in %d\n", err, tempDelay)
                select {
                case <-time.After(tempDelay):
                case <-s.ctx.Done():
                }
                continue
            }
            return err
        }
        tempDelay = 0

        // how many connections do we have ?
        sz := s.conns.Size()
        if sz >= MaxConnections {
            holmes.Warnf("max connections size %d, refuse\n", sz)
            rawConn.Close()
            continue
        }

        if s.opts.tlsCfg != nil {
            rawConn = tls.Server(rawConn, s.opts.tlsCfg)
        }

        netid := netIdentifier.GetAndIncrement()
        sc := NewServerConn(netid, s, rawConn)
        sc.SetName(sc.rawConn.RemoteAddr().String())

        s.mu.Lock()
        if s.sched != nil {
            sc.RunEvery(s.interv, s.sched)
        }
        s.mu.Unlock()

        s.conns.Put(netid, sc)
        addTotalConn(1)

        s.wg.Add(1)
        go func() {
            sc.Start()
        }()

        holmes.Infof("accepted client %s, id %d, total %d\n", sc.GetName(), netid, s.conns.Size())
        s.conns.RLock()
        for _, c := range s.conns.m {
            holmes.Infof("client %s\n", c.GetName())
        }
        s.conns.RUnlock()
    } // for loop
}

如果服務(wù)器在接受客戶端連接請求的時候發(fā)生了臨時錯誤,那么服務(wù)器將等待最多1秒的時間再重新嘗試接受請求,如果現(xiàn)有的連接數(shù)超過了MaxConnections(默認1000),就拒絕并關(guān)閉連接,否則啟動一個新的連接開始工作。

2. 服務(wù)器的優(yōu)雅關(guān)閉

Go語言在發(fā)布1.7版時在標(biāo)準(zhǔn)庫中引入了context包。context包提供的Context結(jié)構(gòu)能夠在服務(wù)器,網(wǎng)絡(luò)連接以及各相關(guān)線程之間建立一種相關(guān)聯(lián)的“上下文”關(guān)系。這種上下文關(guān)系包含的信息是與某次網(wǎng)絡(luò)請求有關(guān)的(request scoped),因此與該請求有關(guān)的所有Go線程都能安全地訪問這個上下文結(jié)構(gòu),讀取或者寫入與上下文有關(guān)的數(shù)據(jù)。比如handleLoop線程會將某個網(wǎng)絡(luò)連接的net ID以及message打包到上下文結(jié)構(gòu)中,然后連同handler函數(shù)一起交給工作者線程去處理:

// handleLoop() - put handler or timeout callback into worker go-routines
func handleLoop(c WriteCloser, wg *sync.WaitGroup) {
    //... omitted ...

    
    for {
        select {
        //... omitted ...
        case msgHandler := <-handlerCh:
            msg, handler := msgHandler.message, msgHandler.handler
            if handler != nil {
                if askForWorker {
                    WorkerPoolInstance().Put(netID, func() {
                        handler(NewContextWithNetID(NewContextWithMessage(ctx, msg), netID), c)
                    })
                } 
            }
        //... omitted ...
    }
}

隨后,在工作者線程真正執(zhí)行時,業(yè)務(wù)邏輯代碼就能在handler函數(shù)中獲取到message或者net ID,這些都是與本次請求有關(guān)的上下文數(shù)據(jù),比如一個典型的echo server就會這樣處理:

// ProcessMessage process the logic of echo message.
func ProcessMessage(ctx context.Context, conn tao.WriteCloser) {
    msg := tao.MessageFromContext(ctx).(Message)
    holmes.Infof("receving message %s\n", msg.Content)
    conn.Write(msg)
}

使用context的另外一個場景是實現(xiàn)服務(wù)器及網(wǎng)絡(luò)連接的“優(yōu)雅關(guān)閉”。服務(wù)器在管理網(wǎng)絡(luò)連接時會將自己的上下文傳遞給它,而網(wǎng)絡(luò)連接啟動新線程時同樣也會將自己的上下文傳遞給這些線程,這些上下文都是可取消(cancelable)的。當(dāng)服務(wù)器需要停機或者連接將要關(guān)閉時,只要調(diào)用cancel函數(shù),所有這些線程就能收到通知并退出。服務(wù)器或者網(wǎng)絡(luò)連接通過阻塞等待這些線程關(guān)閉之后再關(guān)閉,就能最大限度保證正確退出。服務(wù)器關(guān)閉的關(guān)鍵代碼如下:

// Stop gracefully closes the server, it blocked until all connections
// are closed and all go-routines are exited.
func (s *Server) Stop() {
    // immediately stop accepting new clients
    s.mu.Lock()
    listeners := s.lis
    s.lis = nil
    s.mu.Unlock()

    for l := range listeners {
        l.Close()
        holmes.Infof("stop accepting at address %s\n", l.Addr().String())
    }

    // close all connections
    conns := map[int64]*ServerConn{}
    s.conns.RLock()
    for k, v := range s.conns.m {
        conns[k] = v
    }
    s.conns.Clear()
    s.conns.RUnlock()

    for _, c := range conns {
        c.rawConn.Close()
        holmes.Infof("close client %s\n", c.GetName())
    }

    s.mu.Lock()
    s.cancel()
    s.mu.Unlock()

    s.wg.Wait()

    holmes.Infoln("server stopped gracefully, bye.")
    os.Exit(0)
}

3. 網(wǎng)絡(luò)連接模型

在其他的編程語言中,采用Reactor模式編寫的服務(wù)器往往需要在一個IO線程異步地通過epoll進行多路復(fù)用。而因為Go線程的開銷廉價,Go語言可以對每一個網(wǎng)絡(luò)連接創(chuàng)建三個go-routine。readLoop()負責(zé)讀取數(shù)據(jù)并反序列化成消息;writeLoop()負責(zé)序列化消息并發(fā)送二進制字節(jié)流;最后handleLoop()負責(zé)調(diào)用消息處理函數(shù)。這三個協(xié)程在連接創(chuàng)建并啟動時就會各自獨立運行:

// Start starts the server connection, creating go-routines for reading,
// writing and handlng.
func (sc *ServerConn) Start() {
    holmes.Infof("conn start, <%v -> %v>\n", sc.rawConn.LocalAddr(), sc.rawConn.RemoteAddr())
    onConnect := sc.belong.opts.onConnect
    if onConnect != nil {
        onConnect(sc)
    }

    loopers := []func(WriteCloser, *sync.WaitGroup){readLoop, writeLoop, handleLoop}
    for _, l := range loopers {
        looper := l
        sc.wg.Add(1)
        go looper(sc, sc.wg)
    }
}

3.1 核心代碼分析之readLoop

readLoop做了三件關(guān)鍵的工作。首先調(diào)用消息編解碼器將接收到的字節(jié)流反序列化成消息;然后更新用于心跳檢測的時間戳;最后,根據(jù)消息的協(xié)議號找到對應(yīng)的消息處理函數(shù),如果注冊了消息回調(diào)函數(shù),那么就調(diào)用該函數(shù)處理消息,否則將消息和處理函數(shù)打包發(fā)送到handlerCh中,注意其中的cDone和sDone分別是網(wǎng)絡(luò)連接和服務(wù)器上下文結(jié)構(gòu)中的channel,分別用于監(jiān)聽網(wǎng)絡(luò)連接和服務(wù)器的“關(guān)閉”事件通知(下同)。

/* readLoop() blocking read from connection, deserialize bytes into message,
then find corresponding handler, put it into channel */
func readLoop(c WriteCloser, wg *sync.WaitGroup) {
    var (
        rawConn          net.Conn
        codec            Codec
        cDone            <-chan struct{}
        sDone            <-chan struct{}
        setHeartBeatFunc func(int64)
        onMessage        onMessageFunc
        handlerCh        chan MessageHandler
        msg              Message
        err              error
    )

    switch c := c.(type) {
    case *ServerConn:
        rawConn = c.rawConn
        codec = c.belong.opts.codec
        cDone = c.ctx.Done()
        sDone = c.belong.ctx.Done()
        setHeartBeatFunc = c.SetHeartBeat
        onMessage = c.belong.opts.onMessage
        handlerCh = c.handlerCh
    case *ClientConn:
        rawConn = c.rawConn
        codec = c.opts.codec
        cDone = c.ctx.Done()
        sDone = nil
        setHeartBeatFunc = c.SetHeartBeat
        onMessage = c.opts.onMessage
        handlerCh = c.handlerCh
    }

    defer func() {
        if p := recover(); p != nil {
            holmes.Errorf("panics: %v\n", p)
        }
        wg.Done()
        holmes.Debugln("readLoop go-routine exited")
        c.Close()
    }()

    for {
        select {
        case <-cDone: // connection closed
            holmes.Debugln("receiving cancel signal from conn")
            return
        case <-sDone: // server closed
            holmes.Debugln("receiving cancel signal from server")
            return
        default:
            msg, err = codec.Decode(rawConn)
            if err != nil {
                holmes.Errorf("error decoding message %v\n", err)
                if _, ok := err.(ErrUndefined); ok {
                    // update heart beats
                    setHeartBeatFunc(time.Now().UnixNano())
                    continue
                }
                return
            }
            setHeartBeatFunc(time.Now().UnixNano())
            handler := GetHandlerFunc(msg.MessageNumber())
            if handler == nil {
                if onMessage != nil {
                    holmes.Infof("message %d call onMessage()\n", msg.MessageNumber())
                    onMessage(msg, c.(WriteCloser))
                } else {
                    holmes.Warnf("no handler or onMessage() found for message %d\n", msg.MessageNumber())
                }
                continue
            }
            handlerCh <- MessageHandler{msg, handler}
        }
    }
}

3.2 核心代碼分析之writeLoop

writeLoop做了一件事情,從sendCh中讀取已序列化好的字節(jié)流,然后發(fā)送到網(wǎng)絡(luò)上。但是要注意,該協(xié)程在連接關(guān)閉退出執(zhí)行之前,會非阻塞地將sendCh中的消息全部發(fā)送完畢再退出,避免漏發(fā)消息,這就是關(guān)鍵所在。

/* writeLoop() receive message from channel, serialize it into bytes,
then blocking write into connection */
func writeLoop(c WriteCloser, wg *sync.WaitGroup) {
    var (
        rawConn net.Conn
        sendCh  chan []byte
        cDone   <-chan struct{}
        sDone   <-chan struct{}
        pkt     []byte
        err     error
    )

    switch c := c.(type) {
    case *ServerConn:
        rawConn = c.rawConn
        sendCh = c.sendCh
        cDone = c.ctx.Done()
        sDone = c.belong.ctx.Done()
    case *ClientConn:
        rawConn = c.rawConn
        sendCh = c.sendCh
        cDone = c.ctx.Done()
        sDone = nil
    }

    defer func() {
        if p := recover(); p != nil {
            holmes.Errorf("panics: %v\n", p)
        }
        // drain all pending messages before exit
    OuterFor:
        for {
            select {
            case pkt = <-sendCh:
                if pkt != nil {
                    if _, err = rawConn.Write(pkt); err != nil {
                        holmes.Errorf("error writing data %v\n", err)
                    }
                }
            default:
                break OuterFor
            }
        }
        wg.Done()
        holmes.Debugln("writeLoop go-routine exited")
        c.Close()
    }()

    for {
        select {
        case <-cDone: // connection closed
            holmes.Debugln("receiving cancel signal from conn")
            return
        case <-sDone: // server closed
            holmes.Debugln("receiving cancel signal from server")
            return
        case pkt = <-sendCh:
            if pkt != nil {
                if _, err = rawConn.Write(pkt); err != nil {
                    holmes.Errorf("error writing data %v\n", err)
                    return
                }
            }
        }
    }
}

3.3 核心代碼分析之handleLoop

readLoop將消息和處理函數(shù)打包發(fā)給了handlerCh,于是handleLoop就從handlerCh中取出消息和處理函數(shù),然后交給工作者線程池,由后者負責(zé)調(diào)度執(zhí)行,完成對消息的處理。這里很好的詮釋了Go語言是如何通過channel實現(xiàn)Go線程間通信的。

// handleLoop() - put handler or timeout callback into worker go-routines
func handleLoop(c WriteCloser, wg *sync.WaitGroup) {
    var (
        cDone        <-chan struct{}
        sDone        <-chan struct{}
        timerCh      chan *OnTimeOut
        handlerCh    chan MessageHandler
        netID        int64
        ctx          context.Context
        askForWorker bool
    )

    switch c := c.(type) {
    case *ServerConn:
        cDone = c.ctx.Done()
        sDone = c.belong.ctx.Done()
        timerCh = c.timerCh
        handlerCh = c.handlerCh
        netID = c.netid
        ctx = c.ctx
        askForWorker = true
    case *ClientConn:
        cDone = c.ctx.Done()
        sDone = nil
        timerCh = c.timing.timeOutChan
        handlerCh = c.handlerCh
        netID = c.netid
        ctx = c.ctx
    }

    defer func() {
        if p := recover(); p != nil {
            holmes.Errorf("panics: %v\n", p)
        }
        wg.Done()
        holmes.Debugln("handleLoop go-routine exited")
        c.Close()
    }()

    for {
        select {
        case <-cDone: // connectin closed
            holmes.Debugln("receiving cancel signal from conn")
            return
        case <-sDone: // server closed
            holmes.Debugln("receiving cancel signal from server")
            return
        case msgHandler := <-handlerCh:
            msg, handler := msgHandler.message, msgHandler.handler
            if handler != nil {
                if askForWorker {
                    WorkerPoolInstance().Put(netID, func() {
                        handler(NewContextWithNetID(NewContextWithMessage(ctx, msg), netID), c)
                    })
                    addTotalHandle()
                } else {
                    handler(NewContextWithNetID(NewContextWithMessage(ctx, msg), netID), c)
                }
            }
        case timeout := <-timerCh:
            if timeout != nil {
                timeoutNetID := NetIDFromContext(timeout.Ctx)
                if timeoutNetID != netID {
                    holmes.Errorf("timeout net %d, conn net %d, mismatched!\n", timeoutNetID, netID)
                }
                if askForWorker {
                    WorkerPoolInstance().Put(netID, func() {
                        timeout.Callback(time.Now(), c.(WriteCloser))
                    })
                } else {
                    timeout.Callback(time.Now(), c.(WriteCloser))
                }
            }
        }
    }
}

4. 消息處理機制

4.1 消息上下文

任何一個實現(xiàn)了Message接口的類型,都是一個消息,它需要提供方法訪問自己的協(xié)議號并將自己序列化成字節(jié)數(shù)組;另外,每個消息都需要注冊自己的反序列化函數(shù)和處理函數(shù):

// Handler takes the responsibility to handle incoming messages.
type Handler interface {
    Handle(context.Context, interface{})
}

// HandlerFunc serves as an adapter to allow the use of ordinary functions as handlers.
type HandlerFunc func(context.Context, WriteCloser)

// Handle calls f(ctx, c)
func (f HandlerFunc) Handle(ctx context.Context, c WriteCloser) {
    f(ctx, c)
}

// UnmarshalFunc unmarshals bytes into Message.
type UnmarshalFunc func([]byte) (Message, error)

// handlerUnmarshaler is a combination of unmarshal and handle functions for message.
type handlerUnmarshaler struct {
    handler     HandlerFunc
    unmarshaler UnmarshalFunc
}

func init() {
  messageRegistry = map[int32]messageFunc{}
  buf = new(bytes.Buffer)
}

// Register registers the unmarshal and handle functions for msgType.
// If no unmarshal function provided, the message will not be parsed.
// If no handler function provided, the message will not be handled unless you
// set a default one by calling SetOnMessageCallback.
// If Register being called twice on one msgType, it will panics.
func Register(msgType int32, unmarshaler func([]byte) (Message, error), handler func(context.Context, WriteCloser)) {
    if _, ok := messageRegistry[msgType]; ok {
        panic(fmt.Sprintf("trying to register message %d twice", msgType))
    }

    messageRegistry[msgType] = handlerUnmarshaler{
        unmarshaler: unmarshaler,
        handler:     HandlerFunc(handler),
    }
}

// GetUnmarshalFunc returns the corresponding unmarshal function for msgType.
func GetUnmarshalFunc(msgType int32) UnmarshalFunc {
    entry, ok := messageRegistry[msgType]
    if !ok {
        return nil
    }
    return entry.unmarshaler
}

// GetHandlerFunc returns the corresponding handler function for msgType.
func GetHandlerFunc(msgType int32) HandlerFunc {
    entry, ok := messageRegistry[msgType]
    if !ok {
        return nil
    }
    return entry.handler
}

// Message represents the structured data that can be handled.
type Message interface {
    MessageNumber() int32
    Serialize() ([]byte, error)
}

對每個消息處理函數(shù)而言,要處理的消息以及發(fā)送該消息的客戶端都是不同的,這些信息被稱為“消息上下文”,用Context結(jié)構(gòu)表示,每個不同的客戶端用一個64位整數(shù)netid標(biāo)識:

// Context is the context info for every handler function.
// Handler function handles the business logic about message.
// We can find the client connection who sent this message by netid and send back responses.
type Context struct{
  message Message
  netid int64
}

func NewContext(msg Message, id int64) Context {
  return Context{
    message: msg,
    netid: id,
  }
}

func (ctx Context)Message() Message {
  return ctx.message
}

func (ctx Context)Id() int64 {
  return ctx.netid
}

4.2 編解碼器

接收數(shù)據(jù)時,編解碼器(Codec)負責(zé)按照一定的格式將網(wǎng)絡(luò)連接上讀取的字節(jié)數(shù)據(jù)反序列化成消息,并將消息交給上層處理(解碼);發(fā)送數(shù)據(jù)時,編解碼器將上層傳遞過來的消息序列化成字節(jié)數(shù)據(jù),交給下層發(fā)送(編碼):

// Codec is the interface for message coder and decoder.
// Application programmer can define a custom codec themselves.
type Codec interface {
  Decode(Connection) (Message, error)
  Encode(Message) ([]byte, error)
}

Tao框架采用的是“Type-Length-Data”的格式打包數(shù)據(jù)。Type占4個字節(jié),表示協(xié)議類型;Length占4個字節(jié),表示消息長度,Data為變長字節(jié)序列,長度由Length表示。反序列化時,由Type字段可以確定協(xié)議類型,然后截取Length長度的字節(jié)數(shù)據(jù)Data,并調(diào)用已注冊的反序列化函數(shù)處理。核心代碼如下:

// Codec is the interface for message coder and decoder.
// Application programmer can define a custom codec themselves.
type Codec interface {
    Decode(net.Conn) (Message, error)
    Encode(Message) ([]byte, error)
}

// TypeLengthValueCodec defines a special codec.
// Format: type-length-value |4 bytes|4 bytes|n bytes <= 8M|
type TypeLengthValueCodec struct{}

// Decode decodes the bytes data into Message
func (codec TypeLengthValueCodec) Decode(raw net.Conn) (Message, error) {
    byteChan := make(chan []byte)
    errorChan := make(chan error)

    go func(bc chan []byte, ec chan error) {
        typeData := make([]byte, MessageTypeBytes)
        _, err := io.ReadFull(raw, typeData)
        if err != nil {
            ec <- err
            close(bc)
            close(ec)
            holmes.Debugln("go-routine read message type exited")
            return
        }
        bc <- typeData
    }(byteChan, errorChan)

    var typeBytes []byte

    select {
    case err := <-errorChan:
        return nil, err

    case typeBytes = <-byteChan:
        if typeBytes == nil {
            holmes.Warnln("read type bytes nil")
            return nil, ErrBadData
        }
        typeBuf := bytes.NewReader(typeBytes)
        var msgType int32
        if err := binary.Read(typeBuf, binary.LittleEndian, &msgType); err != nil {
            return nil, err
        }

        lengthBytes := make([]byte, MessageLenBytes)
        _, err := io.ReadFull(raw, lengthBytes)
        if err != nil {
            return nil, err
        }
        lengthBuf := bytes.NewReader(lengthBytes)
        var msgLen uint32
        if err = binary.Read(lengthBuf, binary.LittleEndian, &msgLen); err != nil {
            return nil, err
        }
        if msgLen > MessageMaxBytes {
            holmes.Errorf("message(type %d) has bytes(%d) beyond max %d\n", msgType, msgLen, MessageMaxBytes)
            return nil, ErrBadData
        }

        // read application data
        msgBytes := make([]byte, msgLen)
        _, err = io.ReadFull(raw, msgBytes)
        if err != nil {
            return nil, err
        }

        // deserialize message from bytes
        unmarshaler := GetUnmarshalFunc(msgType)
        if unmarshaler == nil {
            return nil, ErrUndefined(msgType)
        }
        return unmarshaler(msgBytes)
    }
}

這里的代碼存在一些微妙的設(shè)計,需要仔細解釋一下。TypeLengthValueCodec.Decode()函數(shù)會被readLoop協(xié)程用到。因為io.ReadFull()是同步調(diào)用,沒有數(shù)據(jù)可讀時會阻塞readLoop協(xié)程。此時如果關(guān)閉網(wǎng)絡(luò)連接,readLoop協(xié)程將無法退出。所以這里的代碼用到了一個小技巧:專門開辟了一個新協(xié)程來等待讀取最開始的4字節(jié)Type數(shù)據(jù),然后自己select阻塞在多個channel上,這樣就不會忽略其他channel傳遞過來的消息。一旦成功讀取到Type數(shù)據(jù),就繼續(xù)后面的流程:讀取Length數(shù)據(jù),根據(jù)Length讀取應(yīng)用數(shù)據(jù)交給先前注冊好的反序列化函數(shù)。注意,如果收到超過最大長度的數(shù)據(jù)就會關(guān)閉連接,這是為了防止外部程序惡意消耗系統(tǒng)資源。

5. 工作者協(xié)程池

為了提高框架的健壯性,避免因為處理業(yè)務(wù)邏輯造成的響應(yīng)延遲,消息處理函數(shù)一般都會被調(diào)度到工作者協(xié)程池執(zhí)行。設(shè)計工作者協(xié)程池的一個關(guān)鍵是如何將任務(wù)散列給池子中的不同協(xié)程。一方面,要避免并發(fā)問題,必須保證同一個網(wǎng)絡(luò)連接發(fā)來的消息都被散列到同一個協(xié)程按順序執(zhí)行;另一方面,散列一定要是均勻的,不能讓協(xié)程“忙的忙死,閑的閑死”。關(guān)鍵還是在散列函數(shù)的設(shè)計上。

5.1 核心代碼分析

協(xié)程池是按照單例模式設(shè)計的。創(chuàng)建時會調(diào)用newWorker()創(chuàng)建一系列worker協(xié)程。

// WorkerPool is a pool of go-routines running functions.
type WorkerPool struct {
    workers   []*worker
    closeChan chan struct{}
}

var (
    globalWorkerPool *WorkerPool
)

func init() {
    globalWorkerPool = newWorkerPool(WorkersNum)
}

// WorkerPoolInstance returns the global pool.
func WorkerPoolInstance() *WorkerPool {
    return globalWorkerPool
}

func newWorkerPool(vol int) *WorkerPool {
    if vol <= 0 {
        vol = WorkersNum
    }

    pool := &WorkerPool{
        workers:   make([]*worker, vol),
        closeChan: make(chan struct{}),
    }

    for i := range pool.workers {
        pool.workers[i] = newWorker(i, 1024, pool.closeChan)
        if pool.workers[i] == nil {
            panic("worker nil")
        }
    }

    return pool
}

5.2 給工作者協(xié)程分配任務(wù)

給工作者協(xié)程分配任務(wù)的方式很簡單,通過hashCode()散列函數(shù)找到對應(yīng)的worker協(xié)程,然后把回調(diào)函數(shù)發(fā)送到對應(yīng)協(xié)程的channel中。對應(yīng)協(xié)程在運行時就會從channel中取出然后執(zhí)行,在start()函數(shù)中。

// Put appends a function to some worker's channel.
func (wp *WorkerPool) Put(k interface{}, cb func()) error {
    code := hashCode(k)
    return wp.workers[code&uint32(len(wp.workers)-1)].put(workerFunc(cb))
}

func (w *worker) start() {
    for {
        select {
        case <-w.closeChan:
            return
        case cb := <-w.callbackChan:
            before := time.Now()
            cb()
            addTotalTime(time.Since(before).Seconds())
        }
    }
}

func (w *worker) put(cb workerFunc) error {
    select {
    case w.callbackChan <- cb:
        return nil
    default:
        return ErrWouldBlock
    }
}

6. 線程安全的定時器

Tao框架設(shè)計了一個定時器TimingWheel,用來控制定時任務(wù)。Connection在此基礎(chǔ)上進行了進一步封裝。提供定時執(zhí)行(RunAt),延時執(zhí)行(RunAfter)和周期執(zhí)行(RunEvery)功能。這里通過定時器的設(shè)計引出多線程編程的一點經(jīng)驗之談。

6.1 定時任務(wù)的數(shù)據(jù)結(jié)構(gòu)設(shè)計

6.1.1 定時任務(wù)結(jié)構(gòu)

每個定時任務(wù)由一個timerType表示,它帶有自己的id和包含定時回調(diào)函數(shù)的結(jié)構(gòu)OnTimeOut。expiration表示該任務(wù)到期要被執(zhí)行的時間,interval表示時間間隔,interval > 0意味著該任務(wù)是會被周期性重復(fù)執(zhí)行的任務(wù)。

/* 'expiration' is the time when timer time out, if 'interval' > 0
the timer will time out periodically, 'timeout' contains the callback
to be called when times out */
type timerType struct {
    id         int64
    expiration time.Time
    interval   time.Duration
    timeout    *OnTimeOut
    index      int // for container/heap
}

// OnTimeOut represents a timed task.
type OnTimeOut struct {
    Callback func(time.Time, WriteCloser)
    Ctx      context.Context
}

// NewOnTimeOut returns OnTimeOut.
func NewOnTimeOut(ctx context.Context, cb func(time.Time, WriteCloser)) *OnTimeOut {
    return &OnTimeOut{
        Callback: cb,
        Ctx:      ctx,
    }
}

6.1.2 定時任務(wù)的組織

定時器需要按照到期時間的順序從最近到最遠排列,這是一個天然的小頂堆,于是這里采用標(biāo)準(zhǔn)庫container/heap創(chuàng)建了一個堆數(shù)據(jù)結(jié)構(gòu)來組織定時任務(wù),存取效率達到O(nlogn)。

// timerHeap is a heap-based priority queue
type timerHeapType []*timerType

func (heap timerHeapType) getIndexByID(id int64) int {
    for _, t := range heap {
        if t.id == id {
            return t.index
        }
    }
    return -1
}

func (heap timerHeapType) Len() int {
    return len(heap)
}

func (heap timerHeapType) Less(i, j int) bool {
    return heap[i].expiration.UnixNano() < heap[j].expiration.UnixNano()
}

func (heap timerHeapType) Swap(i, j int) {
    heap[i], heap[j] = heap[j], heap[i]
    heap[i].index = i
    heap[j].index = j
}

func (heap *timerHeapType) Push(x interface{}) {
    n := len(*heap)
    timer := x.(*timerType)
    timer.index = n
    *heap = append(*heap, timer)
}

func (heap *timerHeapType) Pop() interface{} {
    old := *heap
    n := len(old)
    timer := old[n-1]
    timer.index = -1
    *heap = old[0 : n-1]
    return timer
}

6.2 定時器核心代碼分析

TimingWheel在創(chuàng)建時會啟動一個單獨協(xié)程來運行定時器核心代碼start()。它在多個channel上進行多路復(fù)用操作:如果從cancelChan收到timerId,就執(zhí)行取消操作:從堆上刪除對應(yīng)的定時任務(wù);將定時任務(wù)數(shù)量發(fā)送給sizeChan,別的線程就能獲取當(dāng)前定時任務(wù)數(shù);如果從quitChan收到消息,定時器就會被關(guān)閉然后退出;如果從addChan收到timer,就將該定時任務(wù)添加到堆;如果從tw.ticker.C收到定時信號,就調(diào)用getExpired()函數(shù)獲取到期的任務(wù),然后將這些任務(wù)回調(diào)發(fā)送到TimeOutChannel中,其他相關(guān)線程會通過該channel獲取并執(zhí)行定時回調(diào)。最后tw.update()會更新周期性執(zhí)行的定時任務(wù),重新調(diào)度執(zhí)行。

func (tw *TimingWheel) update(timers []*timerType) {
    if timers != nil {
        for _, t := range timers {
            if t.isRepeat() {
                t.expiration = t.expiration.Add(t.interval)
                heap.Push(&tw.timers, t)
            }
        }
    }
}

func (tw *TimingWheel) start() {
    for {
        select {
        case timerID := <-tw.cancelChan:
            index := tw.timers.getIndexByID(timerID)
            if index >= 0 {
                heap.Remove(&tw.timers, index)
            }

        case tw.sizeChan <- tw.timers.Len():

        case <-tw.ctx.Done():
            tw.ticker.Stop()
            return

        case timer := <-tw.addChan:
            heap.Push(&tw.timers, timer)

        case <-tw.ticker.C:
            timers := tw.getExpired()
            for _, t := range timers {
                tw.GetTimeOutChannel() <- t.timeout
            }
            tw.update(timers)
        }
    }
}

6.3 定時器是怎么做到線程安全的

用Tao框架開發(fā)的服務(wù)器一開始總是時不時地崩潰。有時候運行了幾個小時服務(wù)器就突然退出了。查看打印出來的調(diào)用棧發(fā)現(xiàn)。每次程序都在定時器上崩潰,原因是數(shù)組訪問越界。這就是并發(fā)訪問導(dǎo)致的問題,為什么呢?因為定時器的核心函數(shù)在一個協(xié)程中操作堆數(shù)據(jù)結(jié)構(gòu),與此同時其提供的添加,刪除等接口卻有可能在其他協(xié)程中調(diào)用。多個協(xié)程并發(fā)訪問一個沒有加鎖的數(shù)據(jù)結(jié)構(gòu),必然會出現(xiàn)問題。解決方法很簡單:將多個協(xié)程的并發(fā)訪問轉(zhuǎn)化為單個協(xié)程的串行訪問,也就是將添加,刪除等操作發(fā)送給不同的channel,然后在start()協(xié)程中統(tǒng)一處理:

// AddTimer adds new timed task.
func (tw *TimingWheel) AddTimer(when time.Time, interv time.Duration, to *OnTimeOut) int64 {
    if to == nil {
        return int64(-1)
    }
    timer := newTimer(when, interv, to)
    tw.addChan <- timer
    return timer.id
}

// Size returns the number of timed tasks.
func (tw *TimingWheel) Size() int {
    return <-tw.sizeChan
}

// CancelTimer cancels a timed task with specified timer ID.
func (tw *TimingWheel) CancelTimer(timerID int64) {
    tw.cancelChan <- timerID
}

6.4 應(yīng)用層心跳

陳碩在他的《Linux多線程服務(wù)端編程》一書中說到,維護長連接的服務(wù)器都應(yīng)該在應(yīng)用層自己實現(xiàn)心跳消息:

“在嚴肅的網(wǎng)絡(luò)程序中,應(yīng)用層的心跳協(xié)議是必不可少的。應(yīng)該用心跳消息來判斷對方進程是否能正常工作。”

要使用一個連接來同時發(fā)送心跳和其他業(yè)務(wù)消息,這樣一旦應(yīng)用層因為出錯發(fā)不出消息,對方就能夠立刻通過心跳停止感知到。值得注意的是,在Tao框架中,定時器只有一個,而客戶端連接可能會有很多個。在長連接模式下,每個客戶端都需要處理心跳包,或者其他類型的定時任務(wù)。將框架設(shè)計為“每個客戶端連接自帶一個定時器”是不合適的——有十萬個連接就有十萬個定時器,會有較高的CPU占用率。定時器應(yīng)該只有一個,所有客戶端注冊進來的定時任務(wù)都由它負責(zé)處理。但是如果所有的客戶端連接都等待唯一一個定時器發(fā)來的消息,就又會存在并發(fā)問題。比如client 1的定時任務(wù)到期了,但它現(xiàn)在正忙著處理其他消息,這個定時任務(wù)就可能被其他client執(zhí)行。所以這里采取了一種“先集中后分散”的處理機制:每一個定時任務(wù)都由一個TimeOut結(jié)構(gòu)表示,該結(jié)構(gòu)中除了回調(diào)函數(shù)還包含一個context。客戶端啟動定時任務(wù)的時候都會填入net ID。TCPServer統(tǒng)一接收定時任務(wù),然后從定時任務(wù)中取出net ID,然后將該定時任務(wù)交給相應(yīng)的ServerConn或ClientConn去執(zhí)行:

// Retrieve the extra data(i.e. net id), and then redispatch timeout callbacks
// to corresponding client connection, this prevents one client from running
// callbacks of other clients
func (s *Server) timeOutLoop() {
    defer s.wg.Done()

    for {
        select {
        case <-s.ctx.Done():
            return

        case timeout := <-s.timing.GetTimeOutChannel():
            netID := timeout.Ctx.Value(netIDCtx).(int64)
            if sc, ok := s.conns.Get(netID); ok {
                sc.timerCh <- timeout
            } else {
                holmes.Warnf("invalid client %d\n", netID)
            }
        }
    }
}

三. 也談并發(fā)編程的核心問題和基本思路

當(dāng)我們談?wù)摬l(fā)編程的時候,我們在談?wù)撌裁矗坑靡痪湓捀爬ǎ?strong>當(dāng)多個線程同時訪問一個未受保護的共享數(shù)據(jù)時,就會產(chǎn)生并發(fā)問題。那么多線程編程的本質(zhì)就是怎樣避免上述情況的發(fā)生了。這里總結(jié)一些,有三種基本的方法。

1. 對共享數(shù)據(jù)結(jié)構(gòu)進行保護

這是教科書上最常見的方法了。用各種信號量/互斥鎖對數(shù)據(jù)結(jié)構(gòu)進行保護,先加鎖,然后執(zhí)行操作,最后解鎖。舉個例子,Tao框架中用于網(wǎng)絡(luò)連接管理的ConnMap就是這么實現(xiàn)的:

// ConnMap is a safe map for server connection management.
type ConnMap struct {
    sync.RWMutex
    m map[int64]*ServerConn
}

// NewConnMap returns a new ConnMap.
func NewConnMap() *ConnMap {
    return &ConnMap{
        m: make(map[int64]*ServerConn),
    }
}

// Clear clears all elements in map.
func (cm *ConnMap) Clear() {
    cm.Lock()
    cm.m = make(map[int64]*ServerConn)
    cm.Unlock()
}

// Get gets a server connection with specified net ID.
func (cm *ConnMap) Get(id int64) (*ServerConn, bool) {
    cm.RLock()
    sc, ok := cm.m[id]
    cm.RUnlock()
    return sc, ok
}

// Put puts a server connection with specified net ID in map.
func (cm *ConnMap) Put(id int64, sc *ServerConn) {
    cm.Lock()
    cm.m[id] = sc
    cm.Unlock()
}

// Remove removes a server connection with specified net ID.
func (cm *ConnMap) Remove(id int64) {
    cm.Lock()
    delete(cm.m, id)
    cm.Unlock()
}

// Size returns map size.
func (cm *ConnMap) Size() int {
    cm.RLock()
    size := len(cm.m)
    cm.RUnlock()
    return size
}

// IsEmpty tells whether ConnMap is empty.
func (cm *ConnMap) IsEmpty() bool {
    return cm.Size() <= 0
}

2 多線程并行轉(zhuǎn)化為單線程串行

這種方法在前面已經(jīng)介紹過,它屬于無鎖化的一種編程方式。多個線程的操作請求都放到一個任務(wù)隊列中,最終由一個單一的線程來讀取隊列并串行執(zhí)行。這種方法在并發(fā)量很大的時候還是會有性能瓶頸。

3 采用精心設(shè)計的并發(fā)數(shù)據(jù)結(jié)構(gòu)

最好的辦法還是要從數(shù)據(jù)結(jié)構(gòu)上入手,有很多技巧能夠讓數(shù)據(jù)結(jié)構(gòu)適應(yīng)多線程并發(fā)訪問的場景。比如Java標(biāo)準(zhǔn)庫中的java.util.concurrent,包含了各種并發(fā)數(shù)據(jù)結(jié)構(gòu),其中ConcurrentHashMap的基本原理就是分段鎖,對每個段(Segment)加鎖保護,并發(fā)寫入數(shù)據(jù)時通過散列函數(shù)分發(fā)到不同的段上面,在SegmentA上加鎖并不影響SegmentB的訪問。
處理并發(fā)多線程問題,一定要小心再小心,思考再思考,一不注意就會踩坑

四. 特別鳴謝

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,748評論 6 531
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,165評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,595評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,633評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 71,435評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 54,943評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,035評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,175評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,713評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 40,599評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,788評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,303評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,034評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,412評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,664評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,408評論 3 390

推薦閱讀更多精彩內(nèi)容

  • 原文鏈接:https://github.com/EasyKotlin 在常用的并發(fā)模型中,多進程、多線程、分布式是...
    JackChen1024閱讀 10,760評論 3 23
  • Spring Cloud為開發(fā)人員提供了快速構(gòu)建分布式系統(tǒng)中一些常見模式的工具(例如配置管理,服務(wù)發(fā)現(xiàn),斷路器,智...
    卡卡羅2017閱讀 134,778評論 18 139
  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 172,599評論 25 707
  • 今天背了247個單詞,花費了3個番茄鐘,好累。本來計劃看完最后200頁《和時間做朋友》,但是也未能如愿。 自從正式...
    粉藍閱讀 259評論 0 0
  • A_master閱讀 292評論 0 0