在tcp/ip模型的应用层和传输层中,基于传输层抽象了一系列接口,而此接口即用于我们的实际开发中为socket(套接字)。
socket也叫套接字, 是为了方便程序员进行网络开发而被设计出来的编程接口. socket在OSI七层模型中是属于传输层(TCP,UDP协议)之上 的一个抽象接口.Go语言的标准库net包里对socket封装了一些列实现网络通信的api
net 包是网络相关的核心包。net 里面包含了 http、rpc 等关键包。 在 net 里面,最重要的两个调用:
基本分成两个大阶段。创建连接阶段:
通信阶段:
Listen 是监听一个端口,准备读取数据。它还有几个类似接口,可以直接使用:
这几个方法都是返回 Listener 的具体类,如TCPListener。一般用 Listen 就可以,除非你要依赖于具体的网络协议特性。网络通信用 TCP 还是用 UDP 是一个影响巨大的事情,一般确认了就不会改。
func Serve(addr string) error {
listener, err := net.Listen("tcp", addr)
if err != nil {
return err
}
for {
conn, err := listener.Accept()
if err != nil {
return err
}
go func() {
handleConn(conn)
}()
}
}
复制代码
代码模板就这样。在handleConn里面读取数据-做点操作-写回响应。
处理连接基本上就是在一个 for 循环内:
func handleConn(conn net.Conn) {
for {
// 读数据
bs := make([]byte, 8)
_, err := conn.Read(bs)
if err == io.EOF || err == io.ErrUnexpectedEOF ||
err == net.ErrClosed {
// 一般关闭的错误比较懒得管
// 也可以把关闭错误输出到日志
_ = conn.Close()
return
}
if err != nil {
continue
}
res := handleMsg(bs)
_, err = conn.Write(res)
if err == io.EOF || err == io.ErrUnexpectedEOF ||
err == net.ErrClosed {
_ = conn.Close()
return
}
}
}
复制代码
在读写的时候,都可能遇到错误,一般来说代表连接已经关掉的是这三个:
但是,我建议只要是出错了就直接关闭,这样对客户端和服务端代码都简单。
func handleConnV1(conn net.Conn) {
for {
// 读数据
bs := make([]byte, 8)
_, err := conn.Read(bs)
if err != nil {
// 一般关闭的错误比较懒得管
// 也可以把关闭错误输出到日志
_ = conn.Close()
return
}
res := handleMsg(bs)
_, err = conn.Write(res)
if err != nil {
_ = conn.Close()
return
}
}
}
复制代码
net.Dial 是指创建一个连接,连上远端的服务器。它也是有几个类似的方法:
只有 DialTimeout 稍微特殊一点,它多了一个超时参数。类似于 Listen,这里建议大家直接使用DialTimeout,因为设置超时可以避免一直阻塞。
func Connect(addr string) error {
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
return err
}
defer func() {
_ = conn.Close()
}()
for {
// 发送请求
_, err := conn.Write([]byte("hello"))
if err != nil {
return err
}
res := make([]byte, 8)
// 接收响应
_, err = conn.Read(res)
if err != nil {
return err
}
fmt.Println(string(res))
time.Sleep(time.Second)
}
}
复制代码
这个模板和服务端处理请求的模板也很像。
前面的模板,是在创建了连接之后,就交给另外一个 goroutine 去处理,除了这个位置,还有两个位置:
由上至下:
因为 goroutine 非常轻量,所以即便是在模式一下,对于小型应用来说,性能也可以满足。
在前面的代码里面,创建的接收数据的字节数组都是固定长度的,那么问题在于,在真实的环境下,长度应该是不确定的。比如说发送字符串“Hello”和发送字符串“Hello,world”,这长度就不太一样了,怎么办?
// 假定我们永远用 8 个字节来存放数据长度
const lenBytes = 8
type Server struct {
addr string
}
func (s *Server) StartAndServe() error {
listener, err := net.Listen("tcp", s.addr)
if err != nil {
return err
}
for {
conn, err := listener.Accept()
if err != nil {
return err
}
go func() {
// 直接在这里处理
handleConn(conn)
}()
}
}
func (s *Server) handleConn(conn net.Conn) error {
for {
// 读数据长度
bs := make([]byte, lenBytes)
_, err := conn.Read(bs)
if err != nil {
return err
}
reqBs := make([]byte, binary.BigEndian.Uint64(bs))
// 根据数据长度读取数据
_, err = conn.Read(reqBs)
if err != nil {
return err
}
res := string(reqBs) + ", from response"
// 总长度
bs = make([]byte, lenBytes, len(res)+lenBytes)
// 写入消息长度
binary.BigEndian.PutUint64(bs, uint64(len(res)))
bs = append(bs, res...)
_, err = conn.Write(bs)
if err != nil {
return err
}
}
}
复制代码
在前面的示例代码里面,客户端创建的连接都是一次性使用。然而,创建一个连接是非常昂贵的:
连接池就是事先创建好一定数量的连接从而复用这些创建好的连接,避免频繁的创建连接消耗资源。
(1) silenceper/pool
Github 地址:github.com/silenceper/…
一般连接池处理流程
silenceper/pool Get 方法
// channelPool 存放连接信息
type channelPool struct {
mu sync.RWMutex
conns chan *idleConn // 空闲连接池
factory func() (interface{}, error)
close func(interface{}) error
ping func(interface{}) error
idleTimeout, waitTimeOut time.Duration
maxActive int
openingConns int
connReqs []chan connReq // 被阻塞的Get请求(连接请求)
}
复制代码
// Release 释放连接池中所有连接
func (c *channelPool) Release() {
c.mu.Lock()
conns := c.conns // 修改空闲连接池的引用
c.conns = nil // 将空闲连接池 conns 改为nil
c.factory = nil
c.ping = nil
closeFun := c.close // 修改关闭方法的引用
c.close = nil
c.mu.Unlock()
if conns == nil {
return
}
// 先关闭 channel
close(conns)
// 从 channel 中取出空闲连接然后关闭
for wrapConn := range conns {
//log.Printf("Type %v\n",reflect.TypeOf(wrapConn.conn))
closeFun(wrapConn.conn)
}
}
复制代码
// Get 从pool中取一个连接
func (c *channelPool) Get() (interface{}, error) {
conns := c.getConns() // 防止并发修改,主要是防止 Release方法
if conns == nil {
return nil, ErrClosed
}
for {
select {
case wrapConn := <-conns: // 拿到空闲连接
if wrapConn == nil {
return nil, ErrClosed
}
//判断连接是否超时,超时则丢弃
if timeout := c.idleTimeout; timeout > 0 {
if wrapConn.t.Add(timeout).Before(time.Now()) {
//丢弃并关闭该连接
c.Close(wrapConn.conn)
continue
}
}
//判断连接是否失效,失效则丢弃,如果用户没有设定 ping 方法,就不检查
if c.ping != nil {
if err := c.Ping(wrapConn.conn); err != nil {
c.Close(wrapConn.conn)
continue
}
}
return wrapConn.conn, nil
default: // 没有拿到空闲连接
c.mu.Lock()
log.Debugf("openConn %v %v", c.openingConns, c.maxActive)
// 连接池满了
if c.openingConns >= c.maxActive {
req := make(chan connReq, 1)
c.connReqs = append(c.connReqs, req)
c.mu.Unlock()、
// 阻塞在这里,直到有人放回连接请求
ret, ok := <-req
if !ok {
return nil, ErrMaxActiveConnReached
}
// 这里只检查了连接是否超时
if timeout := c.idleTimeout; timeout > 0 {
if ret.idleConn.t.Add(timeout).Before(time.Now()) {
// 如果超时丢弃并关闭该连接
c.Close(ret.idleConn.conn)
continue
}
}
// 其实也可以考虑继续检查连通性
return ret.idleConn.conn, nil
}
if c.factory == nil {
c.mu.Unlock()
return nil, ErrClosed
}
// 虽然没有空闲连接,但是连接池还没满,直接创建新的连接
conn, err := c.factory()
if err != nil {
c.mu.Unlock()
return nil, err
}
c.openingConns++
c.mu.Unlock()
return conn, nil
}
}
}
复制代码
silenceper/pool Put 方法
// Put 将连接放回pool中
func (c *channelPool) Put(conn interface{}) error {
if conn == nil {
return errors.New("connection is nil. rejecting")
}
c.mu.Lock()
if c.conns == nil {
c.mu.Unlock()
return c.Close(conn)
}
// 有阻塞的Get请求(连接请求), 把连接丢过去先到先得
if l := len(c.connReqs); l > 0 {
req := c.connReqs[0]
copy(c.connReqs, c.connReqs[1:])
c.connReqs = c.connReqs[:l-1]
// 这里将 连接请求 加入到请求阻塞队列中
req <- connReq{
idleConn: &idleConn{conn: conn, t: time.Now()},
}
c.mu.Unlock()
return nil
} else {
select {
// 空闲连接池没满
case c.conns <- &idleConn{conn: conn, t: time.Now()}:
c.mu.Unlock()
return nil
default:
c.mu.Unlock()
// 空闲连接池已满,直接关闭该连接
return c.Close(conn)
}
}
}
复制代码
总结:
(1) 起步
(2) 超过上限
(3) 放回去,有阻塞请求
(4) 放回去空闲连接队列
如果这个时候没有阻塞请求,并且此时空闲连接队列还没有满,那么就放回去空闲连接队列。
(5) 空闲连接队列满了
(6) 从空闲连接队列 GET
它也基本遵循前面总结的:
sql.DB 有很多细节,这里我们只是看它怎么管连接的。
因为本身 DB 比较复杂,所以在 putConn 的时候要做很多校验,维持好整体状态:
这个方法步骤和 silenceper/pool 的 Put 流程几乎一致。
总结:过期时间处理
在 sql.DB 和连接池里面都看到了一个过期时间的处理。在开发中,还有类似的场景,例如说本地缓存的过期时间。可能的方案都是:
空闲连接我们都是放在 channel 里,怎么定期检查?
package net
import (
"net"
"sync"
"sync/atomic"
"time"
)
type Option func(p *SimplePool)
type SimplePool struct {
idleChan chan conn
waitChan chan *conReq
factory func() (net.Conn, error)
idleTimeout time.Duration
maxCnt int32
// 连接数
cnt int32
l sync.Mutex
}
func NewSimplePool(factory func()(net.Conn, error), opts...Option) *SimplePool {
res := &SimplePool {
idleChan: make(chan conn, 16),
waitChan: make(chan *conReq, 128),
factory: factory,
maxCnt: 128,
}
for _, opt := range opts {
opt(res)
}
return res
}
func (p *SimplePool) Get() (net.Conn, error) {
for {
select {
case c := <-p.idleChan:
// 超时,直接关闭.
// 有没有觉得奇怪,就是明明我们就是需要一个连接,但是我们还关闭了
if c.lastActive.Add(p.idleTimeout).Before(time.Now()) {
atomic.AddInt32(&p.cnt, -1)
_ = c.c.Close()
continue
}
return c.c, nil
default:
cnt := atomic.AddInt32(&p.cnt, 1)
if cnt <= p.maxCnt {
return p.factory()
}
atomic.AddInt32(&p.cnt, -1)
req := &conReq{
con: make(chan conn, 1),
}
// 可能阻塞在这两句,对应不同的情况。
// 所以实际上 waitChan 根本不需要设计很大的容量
// 另外,这里需不需要加锁?
p.waitChan <- req
c := <- req.con
return c.c, nil
}
}
}
func (p *SimplePool) Put(c net.Conn) {
// 为什么我只在这个部分加锁,其余部分都不加?
p.l.Lock()
if len(p.waitChan) > 0 {
req := <- p.waitChan
p.l.Unlock()
req.con <- conn{c: c, lastActive: time.Now()}
return
}
p.l.Unlock()
select {
case p.idleChan <- conn{c: c, lastActive: time.Now()}:
default:
defer func() {
atomic.AddInt32(&p.maxCnt, -1)
}()
_ = c.Close()
}
}
// WithMaxIdleCnt 自定义最大空闲连接数量
func WithMaxIdleCnt(maxIdleCnt int32) Option {
return func(p *SimplePool) {
p.idleChan = make(chan conn, maxIdleCnt)
}
}
// WithMaxCnt 自定义最大连接数量
func WithMaxCnt(maxCnt int32) Option {
return func(p *SimplePool) {
p.maxCnt = maxCnt
}
}
type conn struct {
c net.Conn
lastActive time.Time
}
type conReq struct {
con chan conn
}