0%

使用 goroutine 模拟 redis IO 模型

在 redis 6.0 之后,redis 引入了 IO 线程的概念,但是并没有改变原有的单线程执行事务的模式。这是因为网络 IO 和协议解析在一次请求耗时中的占比是非常大的。使用单线程的模型可以简化应用层数据结构的设计,从而使代码更加便于维护。

而 go 语言中,同样可以利用 goroutine 和 channel 来模拟 redis 的请求处理方式,使用单一事务协程和多个协议解析协程的方式来处理客户端请求。这种处理方式不仅能够借助 go 语言的特点,还能够结合传统 reactor 事务模式的特点,使得代码更易于维护。这种模式下,网络 IO 可以分为 Accept Loop, IO Loop, Event Loop,分别完成建立新连接,解析客户端命令,处理客户端请求的功能。

抽象客户端连接

由于 goroutine 不具备传统线程的特点,不能够直接从外部来改变协程的状态。可以将每一个客户端连接所对应的协程抽象为一个 Client Struct,也就是一个 Context 进行管理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type Client struct {

event *Request // 解析后的命令
raw []byte // 解析前的命令
res chan *Response // 回包

cnn net.Conn // 连接实例
id uuid.UUID // Cli 编号
tp time.Time // 通信时间戳

status ClientStatus // 状态 0 等待连接 1 正常 -1 退出 -2 异常
exit bool // 退出标志

}

func NewClient(c net.Conn) *Client {
...
}

每一个 Client Struct 都对应了一个 net.Conn 实例,并运行在一个单独的 goroutine 上。结构体内部存储与该连接相关的所有信息,当不同循环之间需要通信时,可以使用 chan *Client 进行通信,减少数据的拷贝。

Accept Loop

Accept Loop 主要执行 reactor 模型中 Acceptor 对象的功能,监听被动套接字,新连接抽象为Client Struct,并开启协程来处理读写请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 处理新连接
func AcceptLoop() {

...

for !quit{
conn, err := listener.Accept()
if err != nil {
// 错误处理
}

// 建立一个新的 Client 实例
newCli := NewClient(conn)

// 开启协程处理 IO 请求
go IOLoop(newCli) // 这里也可以使用协程池的方式,一个协程绑定多个客户端连接

...
}

IO Loop

IO Loop 主要负责处理 socket 的读写,客户端请求的解析工作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func IOLoop(cli *Client) {


for !cli.exit {

// 读请求
cli.cnn.Read(cli.raw[rd:])

// 解析请求
parseRequest(cli.raw,cli.event)

// 通过 channel 发送通知
events <- cli

// 等待 Event Loop 执行
cli.cnn.Write( <-cli.res)
}

// 通知 Event Loop 释放相关记录
...
}

Event Loop

Event Loop 处理 IO Loop 发送的活跃事件以及定时事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func EventLoop() {

for !quit{
timer := time.NewTimer(time.Second)

select {
case <-timer.C:

// 执行时间事件
execTimeEvent()

case cli := <-events:

// 执行 IO 时间
eventRes := executeIOEvent(cli.event)

// 通知阻塞的客户端
cli.res <- eventRes

}
}
// 执行退出事件
executeShutdownEvents()
}

优缺点

优点

  • 事件流程较为清晰,事件的处理并不存在并发。
  • 可以减少应用层临界资源的竞争。
  • 可以保证所有事件是串行执行的。

缺点

  • 使用管道传递主业务数据,会造成一定的性能损耗。
  • 该模式不适合单个事件执行时间过长的场景。

redis 场景下的对比

在保证协议解析部分,数据库部分(除读写锁)相同的情况下,测试直接使用 goroutine 解析并执行命令(使用读写锁保证互斥)、使用单独的 goroutine 执行命令、redis-server 三者性能。redis-benchmark,测试环境为 MacOS Ventura 13.0,8 G 内存,双核四线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 使用单独 goroutine 串行执行命令
Summary:
throughput summary: 45289.86 requests per second
latency summary (msec):
avg min p50 p95 p99 max
0.611 0.048 0.551 0.919 1.999 3.711

# 直接使用 goroutine 解析并执行命令
Summary:
throughput summary: 42680.32 requests per second
latency summary (msec):
avg min p50 p95 p99 max
0.620 0.048 0.599 0.863 1.279 4.871

# redis-server
Summary:
throughput summary: 76982.29 requests per second
latency summary (msec):
avg min p50 p95 p99 max
0.387 0.152 0.351 0.655 0.799 1.511

经过多次对比,使用单独 goroutine 串行执行命令时,每秒钟执行的命令数比直接使用 goroutine 解析并执行命令多 3000 次左右,并且后者并没有执行 aof 刷盘、更新服务器状态等时间事件。而 redis-server 的每秒请求数保持在 76000 左右,远远高于 go 语言版本。

性能瓶颈

使用 pprof 工具获取压力测试阶段的调用图。可以看到,syscall.read 和 syscall.write 占据了绝大多数的 CPU 时间片,网络 IO 是主要的性能瓶颈。

image-20230108035814657

经过查阅资料,go 官方的 net 库性能较差,尝试选用 gnet 网络库更换 net 库。更换后,进行同样的测试,性能有较大的提升。

1
2
3
4
5
6
# 使用 gnet 网络库
Summary:
throughput summary: 64977.26 requests per second
latency summary (msec):
avg min p50 p95 p99 max
0.590 0.120 0.543 1.079 1.439 2.335

C++ 暴露部分私有接口

C++ 中可以通过桥接类的方式来暴露类的一部分接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Exposure;

class Hidden{
private:
friend class Exposure;

int foo();

int bar();

// 需要隐藏的接口
int hidden();
}

class Exposure{
public:

// 暴露 foo 接口 和 bar 接口
int foo(){
return impl_->foo();
}

int bar(){
return impl_->bar();
}

private:
Hidden *impl_;
}