我正在参加「掘金·启航计划」。主要内容是对于校招练手项目的解析,促进自己更好地理解项目,理解redis。
golang作为广泛用于服务端和云计算领域的编程语言,tcp socket是至关重要的功能。
早期的Tomcat/Apache服务器使用阻塞IO模型。当用户线程发送io请求,内核查看数据是否就绪,如果没有就绪会等待数据就绪,此时用户线程处于阻塞状态。当数据就绪后,内核会将数据拷贝到用户线程,并返回结果。用户线程解除阻塞状态。
阻塞IO模型使用一个线程处理一个连接,需要开启大量线程并且频繁地进行上下文切换,因此效率很低。
O多路复用技术为了解决上面的问题,采用一个线程监听多路连接的方案,是一种同步的IO模型。它使用一个线程监听多个文件句柄,如果没有文件句柄就绪,会阻塞用户线程,交出CPU。一旦某个文件句柄就绪,就会通知应用程序进行相应的读写操作。多个连接复用一个线程,因此IO多路复用需要很少的线程数。
对于主流的操作系统,都提供了IO多路复用技术的实现,如Linux的epoll、freeBSD的kqueue、windows上的iocp。因为epoll等技术提供的接口面向io事件而非面向连接,因此需要编写更复杂的异步代码,开发难度很大。
golang的netpoll基于IO多路复用和goroutine scheduler构建了一个简洁高性能的网络模型,并提供了goroutine-per-connection风格的极简接口。
netpoll:draveness.me/golang/docs…
接下来尝试使用netpoll编写此次的服务器。
Echo服务器
echo服务器: Echo protocal,服务器收到什么就给客户端发送什么
io.EOF
End Of File,表示文件结束的错误,表示输出流结束
bufio.Reader
读取器,将数据从某个资源读到传输缓冲区。在缓冲区中,数据可以被流式传输和使用。
func ListenAndServe(address string) {
listener, err := net.Listen("tcp", address)
if err != nil {
log.Fatal(fmt.Sprintf("listen err :%v", err))
}
defer listener.Close()
log.Println(fmt.Sprintf("bind %s,start listening...", address))
for {
//continue accept
conn, accErr := listener.Accept()
if accErr != nil {
log.Fatal(fmt.Sprintf("accept err:%v", accErr))
}
go Handle(conn)
}
}
func Handle(conn net.Conn) {
reader := bufio.NewReader(conn)
for {
msg, err := reader.ReadString('\n')
if err != nil {
//end of file
if err == io.EOF {
log.Printf(fmt.Sprintf("connection close"))
} else {
log.Printf(fmt.Sprintf("read err:%v", err))
}
return
}
b := []byte(msg)
conn.Write(b)
}
}
func main() {
ListenAndServe(":8000")
}
粘包和拆包问题
TCP是面向连接、可靠的、基于字节流的传输层协议。我们常说的TCP服务器并非是实现TCP协议的服务器,而是基于TCP协议的应用层服务器。应用层大多是面向消息。如http的请求/响应、redis的指令/回复都是以消息为单位进行通信的。
作为应用层,需要从传输层中TCP协议提供的字节流中正确的解析出应用层消息,在这一步,会遇到粘包/拆包问题。
socket允许我们通过read函数读取到一段新的数据,这段数据并不对应一个完整的tcp包。我们使用\n表示消息结束,从read中读取的信息可能存在一下几种情况:
- 收到两段数据 "abc","def\n" 他们属于一条信息,"abcdef\n" 。这是拆包的情况
- 收到一段数据 "abc\ndef\n" 他们属于两条信息 "abc\n" "def\n" 这是粘包的情况
应用层协议一般通过以下几个方面入手,来保证完整的读取信息:
- 消息定长
- 在消息尾部添加特殊分隔符。bufio标准库会缓存收到的数据,直到遇到分隔符才返回。
- 将消息分为header body,并在header中提供body总长度,这种分包方式称为LTV(length type value)包。http协议中使用该策略。当从header中获取body长度后,io.ReadFull会读取制定长度的字节流,从而解析出应用层消息。
关闭连接
生产环境下,需要保证TCP服务器关闭前完成必要的清理工作,包括将完成的正在进行的数据传输、关闭TCP连接等。这种关闭模式称为优雅关闭,可以避免资源泄漏以及客户端未收到完整数据而导致故障。
大致思路是,先关闭listener阻止新连接的进入,然后遍历所有连接逐个关闭。
服务器解析
echo.go
redis所使用的服务器本质上是一个echo服务器,但需要一些并发功能的添加。
type EchoHandler struct {
//保存所有工作状态client(conn)的集合
//并发安全
activeConn sync.Map
//关闭状态标识位
closing atomic.Boolean
}
//Close 关闭服务器 map+atomic.Boolean
func (h *EchoHandler) Close() error {
logger.Info("handler shutting down...")
//关闭closing
h.closing.Set(true)
//遍历map 关闭client
h.activeConn.Range(func(key, value interface{}) bool {
client := key.(*EchoClient)
client.Close()
})
return nil
}
对于服务器handler,其核心方法Handle()来实现服务器对信息的监听
func (h *EchoHandler) Handle(conn net.Conn) {
//确保服务器状态未关闭
if h.closing.Get() {
conn.Close()
return
}
//使用此次服务器连接
client := EchoClient{
conn: conn,
}
//将存活连接存储到map中
h.activeConn.Store(client, struct{}{})
//读取缓冲区的数据
reader := bufio.NewReader(conn)
for {
msg, err := reader.ReadString('\n')
if err != nil {
//end of file
if err == io.EOF {
logger.Info("connection close")
//log.Printf(fmt.Sprintf("connection close"))
h.activeConn.Delete(client)
} else {
//log.Printf(fmt.Sprintf("read err:%v", err))
logger.Warn(err)
}
return
}
//发送数据前 设置一个worker协程 防止连接被(因超时等)关闭
client.Waiting.Add(1)
//模拟关闭时未完成发送
//logger.Info("sleeping...")
//time.Sleep(10*time.Second)
b := []byte(msg)
conn.Write(b)
//信息发送完毕 结束waiting
client.Waiting.Done()
}
}
//EchoClient 抽象客户端连接 面向对象 client+wait绑定 实现同步性
type EchoClient struct {
//tcp连接
conn net.Conn
Waiting wait.Wait
}
//Close 关闭客户端连接 conn/waitGroup
func (client *EchoClient) Close() error {
//确保wg为0 或超时关闭
client.Waiting.WaitWithTimeout(10 * time.Second)
//确保conn关闭
client.conn.Close()
return nil
}
server.go
在本文件中,主要维护与客户端的连接。如定义客户端连接的属性:客户端地址/接收连接最大数/超时时间
//Config tcp server properties
type Config struct {
Address string `yaml:"address"`
MaxCount uint32 `yaml:"max-count"`
Timeout time.Duration `yaml:"timeout"`
}
监听一些终端退出的信号
func ListeneAndServeWithSignal(cfg *Config, handler tcp.Handler) error {
closeChan := make(chan struct{})
//接收信号的channel
sigCh := make(chan os.Signal)
//0.监控终端的一些退出信号
signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGINT)
go func() {
sig := <-sigCh
switch sig {
case syscall.SIGHUP, syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGINT:
//退出协程
closeChan <- struct{}{}
}
}()
//1. 监听tcp协议上的连接
listener, err := net.Listen("tcp", cfg.Address)
if err != nil {
return err
}
logger.Info(fmt.Sprintf("bind:%s,start listening...", cfg.Address))
ListenAndServe(listener, handler, closeChan)
return nil
}
监听close信号,在关闭时优雅退出。同时调用handle(),即处理对客户端连接的监听
//ListenAndServe 监听并提供服务,收到关闭通知后关闭连接
func ListenAndServe(listener net.Listener, handler tcp.Handler, closeChan <-chan struct{}) {
//2. 监听关闭
go func() {
<-closeChan
logger.Info("shutting down...")
_ = listener.Close()
_ = handler.Close()
}()
//如果关闭出现异常 defer释放资源
defer func() {
_ = listener.Close()
_ = handler.Close()
}()
ctx := context.Background()
var waitDone sync.WaitGroup
for {
//3.在tcp连接上监听端口连接
conn, err := listener.Accept()
if err != nil {
break
}
logger.Info("accept link")
waitDone.Add(1)
//开启goroutine处理新连接
go func() {
defer func() {
waitDone.Done()
}()
//4. 连接上的业务逻辑
handler.Handle(ctx, conn)
}()
}
//block
waitDone.Wait()
}
ListeneAndServeWithSignal——>ListenAndServe->Handle
注释
chan struct{}
ch<-struct{}{}
协程中通过chan,会在channel被关闭时返回。 struct{}的channel是一种同步,只有读等待,读等待会在channel被关闭的时候返回。
channel:=make(chan struct{})
go func(){
//...
//读入被close的chan返回零值
channel<-struct{}{}
}()
//在goroutine中加入chan struct{}<-struct{}{} 效果测试 会直接退出协程
func TestEmptyStructGoroutine(t *testing.T) {
log.Println("main() 111")
go func() {
log.Println("foo() 111")
time.Sleep(5 * time.Second)
log.Println("foo() 222")
ch <- struct{}{}
log.Println("foo() 333")
}()
log.Println("main() 222")
log.Println("main() 333")
}
=== RUN TestEmptyStructGoroutine
2022/08/17 15:12:38 main() 111
2022/08/17 15:12:38 main() 222
2022/08/17 15:12:38 main() 333
--- PASS: TestEmptyStructGoroutine (0.00s)
PASS
<-ch
主线程中使用chan和close()相配合。读入被close的channel返回零值。作为停止channel通知所有协程
var ch chan struct{} = make(chan struct{})
func foo() {
log.Println("foo() 111")
time.Sleep(5 * time.Second)
log.Println("foo() 222")
close(ch)
log.Println("foo() 333")
}
func TestEmptyStruct(t *testing.T) {
log.Println("main() 111")
go foo()
log.Println("main() 222")
<-ch
log.Println("main() 333")
}
=== RUN TestEmptyStruct
2022/08/17 14:49:47 main() 111
2022/08/17 14:49:47 main() 222
//<-ch
2022/08/17 14:49:47 foo() 111
2022/08/17 14:49:52 foo() 222
2022/08/17 14:49:52 foo() 333
2022/08/17 14:49:52 main() 333
--- PASS: TestEmptyStruct (5.00s)
PASS
Process finished with the exit code 0
\
sync.WaitGroup
- 为什么需要waitGroup
假设场景: 从request中解析出userID和各种参数,现在根据userID和参数拉取不同纬度的消息,并整合返回给调用方。
假设ABCDE五个服务器,此时的时间消耗是 sum(A,B,C,D,E),而不是max(A,B,C,D,E)
waitGroup就是要实现这个功能,即并行调用各服务,保证调用全部返回后并整合数据
- 用法
一个waitGroup可以等待一组协程结束。
- main协程调用wg.Add(delta int),设置worker协程的个数,创建worker协程
- worker协程结束后,调用wg.Done()
- main协程调用wg.Wait()且被block,直到所有worker协程全部执行结束后返回
func main(){
var wg sync.WaitGroup
for _,task:=range wg.tasks{
task:=task
wg.Add(1)
go func(){
task()
wg.Done()
}()
}
wg.Wait()
}
make(chan os.Signal)
信号(signal)是进程间通讯的方式。一个信号是异步的通知。当信号发送到某个进程中,操作系统会中断进程的正常流程,并进入相应的信号处理函数执行操作,完成后回到中断处继续执行。
Go的信号通知机制通过往一个channel中发送os.Signal实现。创建一个os.Signal channel,使用signal.Notify注册要接收的信号。
func Notify(s chan<-os.Signal,sig ...os.Signal)
该函数会将进程收到的信号signal转发给channel c。自定义转发哪些信号
closeChan:=make(chan struct{})
sigCh:=make(chan os.Signal)
signal.Notify(sigCh,syscall.SIGHUP,syscall.SIGQUIT,syscall.SIGTERM,syscall.SIGINT)
go func(){
sig:=<-sigCh
switch sig{
case syscall.SIGHUP,syscall.SIGQUIT,syscall.SIGTERM,syscall.SIGINT:
closeChan<-struct{}{}
}
}
- SIGHUP ---- 终端控制进程结束时触发
- SIGINT ----- 用户发送INTR字符(ctrl+c)触发
- SIGQUIT ----- 用户发送QUIT字符(ctrl+/)触发
- SIGTEMR ---- 结束程序时触发
维护一个chan struct{}<-struct{}{}可以控制退出协程。
sync.Map
线程安全的map。
- Store
- Delete