1. 使用golang编写tcp服务器

710 阅读6分钟

我正在参加「掘金·启航计划」。主要内容是对于校招练手项目的解析,促进自己更好地理解项目,理解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

studygolang.com/articles/34…

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中读取的信息可能存在一下几种情况:

  1. 收到两段数据 "abc","def\n" 他们属于一条信息,"abcdef\n" 。这是拆包的情况
  2. 收到一段数据 "abc\ndef\n" 他们属于两条信息 "abc\n" "def\n" 这是粘包的情况

应用层协议一般通过以下几个方面入手,来保证完整的读取信息:

  1. 消息定长
  2. 在消息尾部添加特殊分隔符。bufio标准库会缓存收到的数据,直到遇到分隔符才返回。
  3. 将消息分为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{}{}

blog.csdn.net/inthat/arti…

协程中通过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通知所有协程

www.jianshu.com/p/7f45d7989…

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可以等待一组协程结束。

  1. main协程调用wg.Add(delta int),设置worker协程的个数,创建worker协程
  2. worker协程结束后,调用wg.Done()
  3. 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)

colobu.com/2015/10/09/…

信号(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

参考链接:

www.cnblogs.com/Finley/p/11…
github.com/HDT3213/god…