深入分析 RPC 框架,从源码扒开来看「RPCX」

1,467 阅读18分钟

如何理解「服务」和「服务实例」

一个 xClient对应一个「服务」,但可以持有该「服务」的多个「服务实例」。
怎么理解「服务」和「服务实例」?

type UserCenter struct {}

func (u *UserCenter) SignIn() {}

func (u *UserCenter) SignOut() {}

fun Service1() {
    s := server.NewServer()
    s.RegisterName("UserCenter", new(UserCenter), "")
    s.Serve("tcp", ":8080")
}

fun Service2() {
    s := server.NewServer()
    s.RegisterName("UserCenter", new(UserCenter), "")
    s.Serve("tcp", ":8081")
}

fun Service3() {
    s := server.NewServer()
    s.RegisterName("UserCenter", new(UserCenter), "")
    s.Serve("tcp", ":8082")
}

上面的 UserCenter 即一个「服务」,但是为了服务的「高可用」,或者「负载均衡」,我们一般会给一个服务启动多个实例(或者说进程)
比如上面的代码中,我们给 UserCenter 服务启动了 3 个服务实例,分别是 tcp@localhost:8081tcp@localhost:8082tcp@localhost:8083。在生产环境,一般每个服务实例是部署在不同的机器上的(不管是物理机,还是虚拟机/容器),并且根据每台机器的性能配置不同的流量权重,给流量权重越高的「服务实例」分发越多的请求,可以理解为服务的**「负载均衡」**;
下面我制作了一个组件图,更形象的去理解服务以及服务实例。可以看到我们有 UserCenter 以及 OrderCenter 两个服务,分别对外提供“用户”以及“订单”两个领域相关的业务方法,并且都启动了 3 个服务实例。服务一般是不会给用户终端(User Agent/浏览器、App)等直接调用的,而是由业务进程去调用/聚合不同的服务提供的业务方法去对用户终端提供服务。

写 Java 的同学可以把业务进程理解成 Controller 层,服务理解成 Service 层。

「RPCX」规定,一个 xClient 结构体只对应一个服务,例如 UserCenter,业务进程只需要通过 xClient 结构体就可以对 UserCenter 服务发起远程调用,至于最终是调用哪个服务实例、失败的重试机制、网络连接的管理、数据序列化等底层细节都不需要关心,只需要实例化 xClient 结构体时指定配置即可。

XClient 与 xClient

接口 XClient

下面是 XClient 的源码,可以看到它是一个 interface ,是一个服务在客户端(即该服务的调用方)的抽象。
PluginContainer 字面是理解是插件容器,那什么是插件,其实就是 Hook,钩子函数。Selector 就是选择器,主要负责在一次 RPC 调用中,挑选指定的服务实例。
下面看一下 XClient 声明的公共方法:

  • XClient#Go() 是一个异步方法,serviceMethod 即调用者要调用的服务的方法名,args 和 reply 大家也能猜出来,返回值 Call 结构体代表一次 RPC 调用,比较重要的是,Call 结构体中有一个 channel 成员用于获得 RPC 调用的执行结果(该 channel 也可以由 Go 方法的调用者传递),所以 XClient#Go() 方法是一个相对比较 low-level 的方法,去让调用者实现 RPC 异步调用。
  • XClient#Call() 类似 Go 方法,不过是阻塞调用,会等待 RPC 的调用结果返回,且有对失败做重试,而 Go() 方法没有。
  • XClient#Broadcast() 有点意思,即向所有服务实例发起 RPC 调用,这也应证了一个服务会有多个服务实例。
// client/xclient.go

type XClient interface {
	SetPlugins(plugins PluginContainer)
	GetPlugins() PluginContainer
	SetSelector(s Selector)
	ConfigGeoSelector(latitude, longitude float64)
	Auth(auth string)

	Go(ctx context.Context, serviceMethod string, args interface{}, reply interface{}, done chan *Call) (*Call, error)
	Call(ctx context.Context, serviceMethod string, args interface{}, reply interface{}) error
	Broadcast(ctx context.Context, serviceMethod string, args interface{}, reply interface{}) error
	Fork(ctx context.Context, serviceMethod string, args interface{}, reply interface{}) error
	Inform(ctx context.Context, serviceMethod string, args interface{}, reply interface{}) ([]Receipt, error)
	SendRaw(ctx context.Context, r *protocol.Message) (map[string]string, []byte, error)
	SendFile(ctx context.Context, fileName string, rateInBytesPerSecond int64, meta map[string]string) error
	DownloadFile(ctx context.Context, requestFileName string, saveTo io.Writer, meta map[string]string) error
	Stream(ctx context.Context, meta map[string]string) (net.Conn, error)
	Close() error
}

剩下的就不多说了,就是发送文件、下载文件等方法,后面有需要的时候再来看,这里主要理清一些重要的结构体以及方法,下面看一下 XClient 接口的实现。

结构体 xClient

结构体 xClient 是接口 XClient 的实现,下面我们把它扒开来看一下。

  • failMode 和 selectMode 即 RPC 调用失败时的重试机制和服务实例的挑选机制,这个看文档就好了,不作深入。
  • cachedClient 比较重要,可以看到它是一个 Map,这个 map 装啥呢?就是我们上面一直强调的服务实例啦!key 是服务实例注册在服务发现上的连接地址,value 可以看到是 RPCClient,也是一个 interface,代表的就是一个服务实例,本质上就是一个和该服务实例建立好了的网络连接,因为网络连接是一种比较耗时的系统资源,所有才需要 cached,以便复用!那么取名叫作 cachedClient 也很合理嘛,关于 RPCClient 的更多细节,下面会讲到。
type xClient struct {
	failMode     FailMode
	selectMode   SelectMode
	cachedClient map[string]RPCClient
	breakers     sync.Map
	servicePath  string
	option       Option

	mu        sync.RWMutex
	servers   map[string]string
	discovery ServiceDiscovery
	selector  Selector

	slGroup singleflight.Group

	isShutdown bool

	// auth is a string for Authentication, for example, "Bearer mF_9.B5f-4.1JqM"
	auth string

	Plugins PluginContainer

	ch chan []*KVPair

	serverMessageChan chan<- *protocol.Message
}

断路器 xClient#breakers

下面重点讲一下 breaker 断路器。

断路器对电源线路及电动机等实行保护,当它们发生严重的过载或者短路及欠压等故障时能自动切断电路。

type xClient struct {
    // ...
	failMode     FailMode
	selectMode   SelectMode
	cachedClient map[string]RPCClient
	breakers     sync.Map
    // ...
}

breakers ,也是个 map,和 cachedClient 一样,key 是服务实例的网络地址,但 value 是一个断路器,当 selector 挑到了某个服务实例 A,但 xClient 和 A 建立网络连接失败时,xClient 就会触发 A 的断路器的断路,若是 FailOver 失败模式,xClient 就会尝试和下一个服务实例建立网络连接。还没完,等到了下一次 RPC 调用,selector 又挑到了服务实例 A,xClient 从 breakers 里拿到 A 的断路器,就可以判断 A 是否处于断路,如果是,就不会再尝试和 A 建立网络连接了,直接找下一个服务实例了!这样,是不是就避免了客户端一直向一个已经不具备功能的服务实例发送请求建立的网络报文呢?即节省了客户端一次 RPC 调用的时间,又可以让服务器专心的去处理别的还可用的服务实例的请求。 上面,我们还提出了两个问题:

  • 难道一次请求建立失败,就触发断路吗?如果刚好碰到网络波动,一次稍微的网络延时,就让一个服务实例完全不可用,会不会太苛刻了呢?
  • 已经处于断路中的服务实例,难道只要客户端不重启,客户端就一直把这个服务实例视而不见吗?

答案显然都是否定的,细节就看 RPCX 用的断路器的具体实现啦。

并发调用抑制 xClient#slGroup

type xClient struct {
    // ...
	failMode     FailMode
	selectMode   SelectMode
	cachedClient map[string]RPCClient
	slGroup singleflight.Group
    // ...
}

结构体 xClient 中还有一个 slGroup 成员,类型为 singleflight.Group,我把它理解为串行的通道。

package singleflight

type Group struct {
    // ...
}	

// Do executes and returns the results of the given function, making
// sure that only one execution is in-flight for a given key at a
// time. If a duplicate comes in, the duplicate caller waits for the
// original to complete and receives the same results.
// The return value shared indicates whether v was given to multiple callers.
func (g *Group) Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool) {}

// Forget tells the singleflight to forget about a key.  Future calls
// to Do for this key will call the function rather than waiting for
// an earlier call to complete.
func (g *Group) Forget(key string) {}

这个 singleflight.Group 的实现也比较简单,Group#Do() 方法即:对于相同的 key,即 1 个分组,在第一次调用 Do() 方法到调用 Forget() 前的这段时间内的 Do() 函数调用都将阻塞等待并使用第 1 个 Do() 方法的结果。概念比较简单,源码也不复杂,感兴趣的同学可以去看下
这里主要看 RPCX 是怎么使用的,聪明的小伙伴应该也应该猜到了,就是在 xClient 和服务实例建立网络连接的这一过程适和 singleflight.Group 去做并发抑制。key 就是指定的服务实例的网络地址,在不上锁的情况下,就会使用 singleflight.Group 去实现并发控制,下面是缩略过的源码。

注意:RPCClient 是一个 interface, 是 RPCX 抽象的在客户端的服务实例,可以认为是 xClient 持有的和指定的服务实例的一个网络连接,Client 是 RPCClient 的实现,后面会讲到。

// 不上锁的情况下去获取一个和指定的服务实例的可以用的网络连接
// 解释一下:k 就是上面所说的服务实例的网络地址;servicePath 是服务的标识(比如 "UserCenter"),这段代码,包括里面调用到的函数,都没有使用到这个变量,可以不理会
func (c *xClient) getCachedClientWithoutLock(k, servicePath, serviceMethod string) (RPCClient, bool, error) {
    // ...
    var needCallPlugin bool
    // 查找是否有建立了的网络连接
	client = c.findCachedClient(k, servicePath, serviceMethod)
	if client == nil || client.IsShutdown() {
		generatedClient, err, _ := c.slGroup.Do(k, func() (interface{}, error) {
            // 如果没有,或者该网络连接已经断开,则和 k 指定的服务实例建立网络连接
			return c.generateClient(k, servicePath, serviceMethod)
		})
		c.slGroup.Forget(k)
		if err != nil {
			return nil, needCallPlugin, err
		}
		client = generatedClient.(RPCClient)
        
        // 这个东西比较特殊,RPCX 是支持客户端和服务实例之间的全双工通信的,即服务实例可以主动推送消息给客户端,这里主要用户实现这个功能
        client.RegisterServerMessageChan(c.serverMessageChan)
        
		c.setCachedClient(client, k, servicePath, serviceMethod)
	}
	return client, needCallPlugin, nil
}

func (c *xClient) generateClient(k, servicePath, serviceMethod string) (client RPCClient, err error) {
	client = &Client{
		option:  c.option,
		Plugins: c.Plugins,
	}
    // 这里可以看到上面说的断路器
	var breaker interface{}
	if c.option.GenBreaker != nil {
		breaker, _ = c.breakers.LoadOrStore(k, c.option.GenBreaker())
	}
	err = client.Connect(network, addr)
    // 和 k 指定的服务实例建立网络连接失败,触发断路!
	if err != nil {
		if breaker != nil {
			breaker.(Breaker).Fail()
		}
		return nil, err
	}
	return client, err
}

这里,我们再次提出一个问题,除了建立网络连接这个场景,还有什么别的可能的场景可以用上 singleflight.Group 呢?

  • 读取本地文件、本地图片、本地音频等,当第一次调用的结果还没有返回时,其余的调用并不会实际去发起 read 系统调用,转而等待第一次调用的结果,虽然说读取文件这种行为,内核会有 PageCache 作缓存,并发下读取一个文件命中内核缓存的几率会非常大,但是内核态和用户态之间的内存拷贝是少不了的。关于 Linux 内核的 IO 操作,感兴趣的朋友可以来这里
  • 进阶点,读取存储在 OSS 的文件。

相同点在于:

  • 这会是一个耗时的操作,而且不是 CPU 耗时,反而 CPU 此时可能会是闲置的,线程是会被挂起的,比如 CPU 和外部设备的 IO 行为。
  • 短时间内,多次的调用,执行结果是相差不大的。

揭秘 xClient 的实例化过程

xClient 的实例化过程还是比较简单的,只从「服务发现器」初始化了一下「服务实例」的连接地址,没有马上和至少一个「服务实例」建立起来网络连接,只有在 RPC 调用时,发现没有和「服务实例」的可用连接,才进行建立。那么,这样的「懒加载」策略,是否会导致当流量打到一个刚上线的应用时,大量的请求因为 RPC 调用而阻塞在和「服务实例」建立网络连接的过程?那线程池是否会有同样的问题呢?区别于 RPC 调用,线程池一般是用来进行任务的异步处理的,对于线程的创建过程所产生的时间消耗可以认为是不敏感的。

// client/xclient.go

func NewXClient(servicePath string, failMode FailMode, selectMode SelectMode, discovery ServiceDiscovery, option Option) XClient {
    client := &xClient{
        failMode:     failMode,
        selectMode:   selectMode,
        discovery:    discovery,
        // 服务的名称,比如之前说的 “UserCenter” 服务。
        servicePath:  servicePath,
        // 缓存着的「服务实例」,即已经建立好了的网络连接
        cachedClient: make(map[string]RPCClient),
        option:       option,
    }
    
    // 从「服务发现器」获取「服务实例」的连接地址,及其元数据(一个 url query 格式的字符串)。
    pairs := discovery.GetServices()
    sort.Slice(pairs, func(i, j int) bool {
        return strings.Compare(pairs[i].Key, pairs[j].Key) <= 0
    })
    servers := make(map[string]string, len(pairs))
    for _, p := range pairs {
        servers[p.Key] = p.Value
    }
    
    // 过滤出来和当前 xClient 的同一个「分组」的「服务实例」
    filterByStateAndGroup(client.option.Group, servers)
    
    // 初始化一下「服务实例」的连接地址
    client.servers = servers
    
    // 根据选项初始化指定的「服务实例选择器」
    if selectMode != Closest && selectMode != SelectByUser {
        client.selector = newSelector(selectMode, servers)
    }
    
    client.Plugins = &pluginContainer{}
    
    // 1. 如果是「zookeeper」、「etcd」等可实时监测「服务实例」上线/下线的「服务发现器」,
    //    则开启注册一个 channel,该 channel 的元素类型是一个承载着「服务实例」连接地址及其元数据的数组。
    //    所以每次都是全量更新最新的「服务实例」的。
    // 
    // 2. 如果是 「peer2peer2」、「MultipleServers」,WatchService() 返回 nil。
    ch := client.discovery.WatchService()
    if ch != nil {
        client.ch = ch
        go client.watch(ch)
    }
    
    return client
}

func (c *xClient) watch(ch chan []*KVPair) {
	for pairs := range ch {
		servers := make(map[string]string, len(pairs))
		for _, p := range pairs {
			servers[p.Key] = p.Value
		}
		filterByStateAndGroup(c.option.Group, servers)
		c.servers = servers
		if c.selector != nil {
			c.selector.UpdateServer(servers)
		}
	}
}

总结

至此,xClient 这一重要的结构体基本上我们已经解读完毕了,下面从中得出的一些重点:

  • 网络连接复用,xClient 结构体内部缓存着已经建立好了的和「服务实例」网络连接。
  • 断路器,当某一个「服务实例」异常时,会触发客户端到该「服务实例」这条通路的断路,企图前往该「服务实例」的请求会快速失败,但是会有一定的补偿策略(比如周期性测试通路)。
  • 并发调用抑制,一段时间内,一批次的对同一个「服务实例」的连接建立申请,该批次的所有申请会阻塞等待该批次中第 1 个调用的结果。
  • 全双工通信,RPCX 是支持客户端监听服务实例主动推送到客户端的消息的。

以及上面我们提出的问题:

RPCClient 与 Client

上面我们接触完了 xClient,知道了 xClient 代表一个「服务」,接下来,我们来看一下 RPCClient 与 Client 所代表的「服务实例」。
和 XClient 与 xClient 相类似,RPCClient 是一个 interface,是「服务实例」在客户端/调用方的抽象,Client 是结构体,是 RPCClient 的具体实现。

接口 RPCClient

Connect() 和 Close() 毫无疑问是作为网络连接建立和关闭的两个方法,network 是网络协议,如 tcp、quic 甚至是 http(当然几乎不会有用 http 作为 RPC 网络协议的时候)。GetConn() 也是一个相对比较 low-level 的方法,直接让我们拿到底层的网络连接,这里可以看到 RPCX 是使用 Golang 的 net 标准包来做底层的网络连接的。

这样,下一步我们可以通过去看 Golang 的 net 标准包的源码来看 RPCX 在底层网络 IO 这块有没有改进空间了。比如 net 包底层是否是 reactor 模型呢?

Go() 和 Call() 两个方法,和 XClient interface 是一样的,不多赘述了,SendRaw() 也是一个 low-level 的方法,protocol.Message 结构体类似 HTTP 报文,是 RPCX 自己的应用层报文协议,具体看这里

// client/client.go

type RPCClient interface {
    Connect(network, address string) error
    Close() error
	RemoteAddr() string
    GetConn() net.Conn
    
	Go(ctx context.Context, servicePath, serviceMethod string, args interface{}, reply interface{}, done chan *Call) *Call
	Call(ctx context.Context, servicePath, serviceMethod string, args interface{}, reply interface{}) error
	SendRaw(ctx context.Context, r *protocol.Message) (map[string]string, []byte, error)


	RegisterServerMessageChan(ch chan<- *protocol.Message)
	UnregisterServerMessageChan()

	IsClosing() bool
	IsShutdown() bool

}

结构体 Client

下面来看下 RPCClient 的具体实现。r *bufio.Reader用于从 Conn net.Conn网络连接中读取「服务实例」发送过来的数据,可以理解为底层网络连接的包装类。
重点讲 seq uint64pending map[uint64]*Callseq是一个单调递增的计数值 counter,该 Client 结构体所代表的「服务实例」每进行一次 RPC 调用前,会进行seq++ 作为此次 RPC 调用的唯一标识 MessageIDpending map[uint64]*Call 中存储着正在等待执行结果的 RPC 请求,key 毫无疑问就是刚才的 MessageID,value 是一个 Call 结构体(RPCX 抽象的客户端发出的一次 RPC 请求)。此时,MessageIDpending这两个结构体成员就构成了客户端和该服务实例的所有 RPC 调用的上下文了,MessageID 会随着一次 RPC 请求发送给服务实例,服务实例处理完这次 RPC 请求后会把 MessageID 和执行结果一起返回给客户端,客户端通过这个 MessageID 就可以从 pending 中找到对应的 Call结构体,就可以通过 Call结构体中的 Done chan *Call 唤醒发起这一次 RPC 请求的协程/线程了。更多的细节在后面揭秘一次 RPC 调用的章节中会讲到。

// client/client.go

type Client struct {
	option Option

	Conn net.Conn
	r    *bufio.Reader
	// w    *bufio.Writer

	mutex        sync.Mutex // protects following
	seq          uint64
	pending      map[uint64]*Call
	closing      bool // user has called Close
	shutdown     bool // server has told us to stop
	pluginClosed bool // the plugin has been called

	Plugins PluginContainer

	ServerMessageChan chan<- *protocol.Message
}

// Call represents an active RPC.
type Call struct {
	ServicePath   string            // The name of the service and method to call.
	ServiceMethod string            // The name of the service and method to call.
	Metadata      map[string]string // metadata
	ResMetadata   map[string]string
	Args          interface{} // The argument to the function (*struct).
	Reply         interface{} // The reply from the function (*struct).
	Error         error       // After completion, the error status.
	Done          chan *Call  // Strobes when call is complete.
	Raw           bool        // raw message or not
}

与「服务实例」建立连接

结构体 Client 是「服务实例」在客户端的实体,本质是持有一条和「服务实例」建立的网络连接,接着上一次继续讲到的内容,我们通过看源码来了解 RPCX 的客户端是如何与「服务实例」建立连接的。

// client/connection.go

type ConnFactoryFn func(c *Client, network, address string) (net.Conn, error)

var ConnFactories = map[string]ConnFactoryFn{
	"http": newDirectHTTPConn,
	"kcp":  newDirectKCPConn,
	"quic": newDirectQuicConn,
	"unix": newDirectConn,
	"memu": newMemuConn,
}

// Connect connects the server via specified network.
func (client *Client) Connect(network, address string) error {
	var conn net.Conn
	var err error

	switch network {
	case "http":
		conn, err = newDirectHTTPConn(client, network, address)
	case "ws", "wss":
		conn, err = newDirectWSConn(client, network, address)
	default:
		fn := ConnFactories[network]
		if fn != nil {
			conn, err = fn(client, network, address)
		} else {
			conn, err = newDirectConn(client, network, address)
		}
	}

	if err == nil && conn != nil {
		if tc, ok := conn.(*net.TCPConn); ok && client.option.TCPKeepAlivePeriod > 0 {
			_ = tc.SetKeepAlive(true)
			_ = tc.SetKeepAlivePeriod(client.option.TCPKeepAlivePeriod)
		}

		if client.option.IdleTimeout != 0 {
			_ = conn.SetDeadline(time.Now().Add(client.option.IdleTimeout))
		}

		if client.Plugins != nil {
			conn, err = client.Plugins.DoConnCreated(conn)
			if err != nil {
				return err
			}
		}

		client.Conn = conn
		client.r = bufio.NewReaderSize(conn, ReaderBuffsize)
		// c.w = bufio.NewWriterSize(conn, WriterBuffsize)

		// start reading and writing since connected
		go client.input()

		if client.option.Heartbeat && client.option.HeartbeatInterval > 0 {
			go client.heartbeat()
		}

	}

	if err != nil && client.Plugins != nil {
		client.Plugins.DoConnCreateFailed(network, address)
	}

	return err
}

首先就是根据不同网络协议通过不同的方式创建网络连接,可以看到 RPCX 支持很多网络协议,甚至支持 http 协议。

当然了,几乎不会有人想使用 http 协议来作为 RPC 的网络通讯协议,对于 RPC 这个场景来说,HTTP 的请求头、响应头太冗余了,一来增加网络传输的时间,二来增加 CPU 序列化、反序列化的时间。

尽管网络协议可以千变万化,但是网络 IO 是不变的,本质都是把用户态的一段内存空间的数据写入到内核态中对应的 socket 的发送缓冲区(发送数据)或者把该 socket 的接收缓存区的一段数据写入到用户态的一段内存空间中(接收数据),这部分内容 Golang 的 net 标准包的 Conn 包装类帮我们实现了,所以对于不同的网络协议,最后 PRCX 还是持有的 net.Conn 这个结构体。
建立完连接后,如果我们有指定 keep-alive 时长,就是设置 keep-alive了,keep-alive 机制其实也就是通信的双方的内核约定好这个 socket 什么时候到期,到期了就四次挥手说拜拜,没到期之前双方的 socket 一直都有效。

keep-alive 机制对于 RPC 场景来说毫无疑问是必要的。三次握手建立连接和四次挥手断开连接一共 7 次的网络 IO 了,省下这 7 次的网络 IO 可以多进行至少 3 次的 RPC 调用,也就有至少 3 倍的性能差距。

net.Dialer是 Golang 封装的用于与指定的网络地址创建连接的结构体,取名“拨号”,很形象。这里的注释告诉我们,在建立网络连接的时候,默认的 keep-alive 时长是 15 秒。

package net

type Dialer struct {
        
    // ...

	// KeepAlive specifies the interval between keep-alive
	// probes for an active network connection.
	// If zero, keep-alive probes are sent with a default value
	// (currently 15 seconds), if supported by the protocol and operating
	// system. Network protocols or operating systems that do
	// not support keep-alives ignore this field.
	// If negative, keep-alive probes are disabled.
	KeepAlive time.Duration
    
    // ...
}

整完 keep-alive 后,还要再整一个 IdleTimeout 时长,即最大空闲时长的意思。

// ...
if client.option.IdleTimeout != 0 {
    _ = conn.SetDeadline(time.Now().Add(client.option.IdleTimeout))
}
// ...

可以看到,RPCX 是直接使用 Golang 标准包的 net.Conn#SetDeadline 方法来完成空闲连接的回收的,从下面的注释可以看到,对一个已经过了 deadline 时间(后面统称:“过期”)的连接进行的 IO 操作会直接返回 ErrDeadlineExceeded 异常,正在被内核挂起着的线程也会直接返回 ``ErrDeadlineExceeded` 异常。

type Conn struct {	
    // A deadline is an absolute time after which I/O operations
	// fail instead of blocking. The deadline applies to all future
	// and pending I/O, not just the immediately following call to
	// Read or Write. After a deadline has been exceeded, the
	// connection can be refreshed by setting a deadline in the future.
	//
	// If the deadline is exceeded a call to Read or Write or to other
	// I/O methods will return an error that wraps os.ErrDeadlineExceeded.
	// This can be tested using errors.Is(err, os.ErrDeadlineExceeded).
	// The error's Timeout method will return true, but note that there
	// are other possible errors for which the Timeout method will
	// return true even if the deadline has not been exceeded.
	//
	// An idle timeout can be implemented by repeatedly extending
	// the deadline after successful Read or Write calls.
	//
	// A zero value for t means I/O operations will not time out.
	SetDeadline(t time.Time) error
}

我看了 RPCX 的源码,发现 RPCX 并不会对 ErrDeadlineExceeded 这个异常做特殊的处理,而且它也不保证你不会拿到一条已经过期的连接进行 RPC 调用。这会出现什么情况:

  • 写失败:向「服务实例」发送 RPC 请求报文的时候,因为底下的 net.Conn 连接已经过期,导致写失败。
  • 读失败:后面会讲到,Client 结构体在和「服务实例」建立完连接后,会启动一个协程「**阻塞轮询」**来自「服务实例」的报文,若此时这条连接过期了,内核会直接给这个「轮询读协程」返回 ErrDeadlineExceeded 异常,RPCX 也没有对这个异常作特殊处理,而是直接调用 Client#Close 方法关闭 Client 结构体,此时 pending 着的 RPC 请求全部返回 ErrShutdown 异常,而不管「服务实例」那边是不是已经处理完这些 RPC 请求了。就会出现一个请求,服务端那边是处理成功的,而客户端这边以为是失败,若服务端没有做幂等处理,那就麻烦了。

具体可以看 Client#input 方法,就是上面说的「轮询读协程」,后面也会马上讲到这个方法。

RPCX 是如何拆包的

// todo

揭秘一次 RPC 调用

// todo

RPCX 的 client 端类图

下面是我刚开始看 RPCX 的 client 客户端的源码时,梳理出来的比较完整的类图,可能会有少许小瑕疵,但是大体上是正确的,相信对大家理解 RPCX 的客户端的设计会有作用。