池化技术 in Golang

2,020 阅读7分钟

池化技术

池概念

  1. 池的预分配

服务器的硬件资源“充裕”,那么提高性能的方式是以空间换时间: 池其实是一组资源的集合, 这组资源在服务器启动之初就被完全创建好并初始化,称为静态资源分配。

  1. 池的利用: 当服务器开始处理客户请求时,如果它需要相关资源就可以直接从池中获取,无需动态分配; 处理完后可以放回池中,而不需要系统调用释放资源,池相当于服务器管理系统资源的应用层设施
  2. 如何预分配多少资源?
    1. 预分配足够多的资源。即针对每个可能的客户连接都分配必要的资源;不过这个会导致资源浪费。
    2. 预分配一定的资源,随后不够之后再动态分配。

优缺点

  1. 优点: 避免了频繁创建分配系统资源,避免了内核的频繁访问
  2. 缺点: 实现肯定会复杂了,容易出错; 因为动态分配的控制,动态分配多少,维持池的资源数目在一个什么范围?何时进行回收都是一个问题。

池的分类

各种Pool

  • 内存池:
    • 内存池通常用于socket的接收缓存和发送缓冲。 对于HTTP请求预先分配一个比如5K字节的缓冲区是合理的。
    • 当客户请求长度大于缓冲区可以选择丢弃或动态扩大。
  • 线程池: 线程池就是并发处理,比如处理新的request就会直接从池子中取出线程实例来处理,而无需pthread_create
  • 进程池:同上,无需fork。
  • 连接池:
    • WHAT: 这个通常特指数据库的连接池,因为这是属于服务器内部或服务器与db之间的永久连接;每个逻辑单元都需要频繁访问这个db。
    • WHY:打开一个数据库连接是非常昂贵的操作,因为网络session、验证、权限校验等逻辑会比较重。一旦关闭了再次建立会造成较大的损耗,所以一般都会搞个连接池来进行复用。
    • HOW: 预先创建一组连接集合。当逻辑单元要访问db,直接从连接池取出一个连接的实例并使用;完成访问后返还连接给连接池。

Pool的基础实现

实现线程池的基础: thread safe -->

go的线程安全实现

  1. 实现一个线程池的结构体
    • 需要有互斥锁来保障读写的并发安全,另一个是有conns来存储连接。
    • conns 其实是** chan net.Conn**类型
type channelPool struct {
    // storage for our net.Conn connections
    mu    sync.Mutex
    conns chan net.Conn
    // net.Conn generator
    factory Factory
}
  1. 创建连接池:
  • 工厂方法用于创建连接,是大于0的并填充池。
  • 指定了initialCap 以及 maximum cap并基于bufferChannel来存放连接~
  • 利用channel来传递connection的好处是线程安全,没有conn的时候会阻塞,不会造成panic。
func NewChannelPool(initialCap, maxCap int, factory Factory) (Pool, error) {
    if initialCap < 0 || maxCap <= 0 || initialCap > maxCap {
        return nil, errors.New("invalid capacity settings")
    }
    c := &channelPool{
        conns:   make(chan net.Conn, maxCap),
        factory: factory,
    }
    // create initial connections, if something goes wrong,
    // just close the pool error out.
    for i := 0; i < initialCap; i++ {
        conn, err := factory()
        if err != nil {
            c.Close()
            return nil, fmt.Errorf("factory is not able to fill the pool: %s", err)
        }
        c.conns <- conn
    }
    return c, nil
}
  1. 获取连接
  • 获取Conn的时候必须是用互斥锁保护conn,否则会造成并发读写出错。
  • 如果没有connection,就会创建一个新的connection;
func (c *channelPool)Get() (net.Conn, error) {
    conns, factory := c.getConnsAndFactory()
    if conns == nil  { return nil, ErrClosed}
    select {
    case conn := <- conns:   
        if conn == nil {
            return nil , ErrClosed
        }
        return c.wrapConn(conn),nil                default:
        conn, err := factory()
        if err = nil  { return nil, err }      
        return c.wrapConn(conn), nil                        
    }
}

func (c *channelPool) getConnsAndFactory() (chan net.Conn, Factory) {
    c.mu.Lock()
    conns := c.conns
    factory := c.factory
    c.mu.Unlock()
    return conns, factory
}
  1. 归还连接

将连接放回池子,如果pool是满的或closed,那么conn会被关闭。

func (c *channelPool) put (conn net.Conn) error {
    if conn == nil { return errors.New("Connection is nil, rejecting") }
    c.mu.RLock()
    defer c.mu.RUnlock()
    if c.conns == nil { return conn.Close() }
    select {
        case c.conns <- conn:
            return nil
        default:
            return conn.Close()
    }
}
  1. 关闭连接
  • 关闭连接池必须上锁,因为要对结构体里面的conns进行写。即标记为nil。
  • 同时调用close( chan ) 即关闭通道。那么其他想要读取这个channel的方法就会返回nil。
func (p *channelPool)Close() error {
    c.mu.Lock()
    c.conns = nil
    c.factory = nil
    c.mu.Unlock()
    if conns == nil {return }
    close(conns)
    for conn := range conns { conn.Close() }
}

总结点

  1. 连接池的最大数量要有限制,如果线程池空的话我们默认返回一个新连接;一旦并发量高就会不断新建连接,很容易造成too much connection
  2. 最大的connection可以约定,然后空闲的时候也希望维护一定的空闲连接 idleNum。
  3. 问题 如何解决连接长期idle的问题? 另外是如何解决重复建立扩张新连接的问题? --- 待完善

数据库连接池

基础实现原理

连接池保持连接活跃,所以连接可以稍后再请求。

image.png

  1. client请求,检查pool是否有连接
  2. 有连接就直接返回给client
  3. 否则创建新的连接
  4. 新的请求来,是关闭连接的请求,这时放到连接池里面;
  5. 稍后的某个时间真正地被关闭。

连接池的动态变化过程

image.png   

基础实现

    public class ConnectionPool where T : IDisposable, new()
    {
        private ConcurrentStack mPool = new ConcurrentStack();
        public T Pop()
        {
            if (!mPool.TryPop(out T item))
            {
                item = new T();

            }
            return item;
        }
        public void Push(T item)
        {
            mPool.Push(item);
        }
    }

可以基于栈来实现,栈存的是一个连接对象,Pop和Push就是弹出连接和压入连接。

栈实现

  • 增加最大限制

最大连接数量的限制,即当连接数过多,就需要判断。

  • 负载满了

负载满了返回空的话,sleep不是好的做法,直接抛异常也是问题;最好的做法是事件驱动,即通过事件来通知, 本质是实现一个队列,负载满了后Push到队列里面,

sql.DB连接池

标准库提供了一个通用的数据库连接池,通过MaxOpenConns和MaxIdelConn控制最大连接数和最大IDEL连接数。

用法

db.Open()
db.DB().SetMaxIdleConns(c.Gorm.MaxIdleConns)
db.DB().SetMaxOpenConns(c.Gorm.MaxOpenConns)
db.DB().SetConnMaxLifetime(time.Duration(c.Gorm.MaxLifetime) * time.Second)

源代码:

type DB struct {
	// Atomic access only. At top of struct to prevent mis-alignment
	waitDuration int64 // Total time waited for new connections.
	connector driver.Connector
	// numClosed is an atomic counter which represents a total number of closed connections. Stmt.openStmt checks it before cleaning closed connections in Stmt.css.
	numClosed uint64
	mu           sync.Mutex // protects following fields
	freeConn     []*driverConn
	connRequests map[uint64]chan connRequest
	nextRequest  uint64 // Next key to use in connRequests.
	numOpen      int    
	openerCh          chan struct{}
	closed            bool
	dep               map[finalCloser]depSet
	lastPut           map[*driverConn]string // stacktrace of last conn's put; debug only
	maxIdleCount      int                    // zero means defaultMaxIdleConns; negative means 0
	maxOpen           int                    // <= 0 means unlimited
	maxLifetime       time.Duration          // maximum amount of time a connection may be reused
	maxIdleTime       time.Duration          // maximum amount of time a connection may be idle before being closed
	cleanerCh         chan struct{}
	waitCount         int64 // Total number of connections waited for.
	maxIdleClosed     int64 // Total number of connections closed due to idle count.
	maxIdleTimeClosed int64 // Total number of connections closed due to idle time.
	maxLifetimeClosed int64 // Total number of connections closed due to max connection lifetime limit.

}
  • freeConn: 是一是空闲的连接切片 conn(基于[]*driver.Conn)
  • maxIdle : 最大空闲连接数。
  • numOpen: 已打开的连接以及等待打开的连接(pending open conn)用于表示需要新连接的信号;

1. 打开连接:

  • Open实际并没有建立实际的数据库连接, openNewConnection在goroutine中运行;
  • 使用mutex进行锁保护,对db .numOpen进行处理, 因此open方法是并发安全的。
  • 维护池子中的idle connection, 只要有出错,即连接fail 或者 连接已关闭 就要把numOpen--。
func (db *DB) openNewConnection(ctx context.Context) {
	// maybeOpenNewConnections has already executed db.numOpen++ before it sent
	ci, err := db.connector.Connect(ctx)
	db.mu.Lock()
	defer db.mu.Unlock()
	if db.closed {
		if err == nil { ci.Close()}
		db.numOpen--
		return
	}
	if err != nil {
		db.numOpen--
		db.putConnDBLocked(nil, err)
		db.maybeOpenNewConnections()
		return
	}
	dc := &driverConn{ // 	}
	if db.putConnDBLocked(dc, err) {
		db.addDepLocked(dc, dc)
	} else {
		db.numOpen--
		ci.Close()
	}
}

打开连接,就需要把请求数减少,增加新的numOpen标志。

func (db *DB) maybeOpenNewConnections() {
	numRequests := len(db.connRequests)
	if db.maxOpen > 0 {
		numCanOpen := db.maxOpen - db.numOpen
		if numRequests > numCanOpen {
			numRequests = numCanOpen
		}
	}
	for numRequests > 0 {
		db.numOpen++ // optimistically
		numRequests--
		if db.closed {
			return
		}
		db.openerCh <- struct{}{}
	}
}

2.设置空闲连接或最大连接数

func (db *DB) SetMaxOpenConns(n int) {
	db.mu.Lock()
	db.maxOpen = n
	if n < 0 { db.maxOpen = 0 }
	syncMaxIdle := db.maxOpen > 0 && db.maxIdleConnsLocked() > db.maxOpen
	db.mu.Unlock()
	if syncMaxIdle {
		db.SetMaxIdleConns(n)
	}
}

 freeConn : 连接池内部存储连接的结构 freeConn;

3.获取连接

  • 实际在Query里面调用conn()
  • FreeConn这个切片有空闲的话就left Pop出列(加锁操作)。
  • connRequest (本质是map)
    • 1) 一个类似排队机制的 connRequest,当空闲的连接为空时,这边将会新建一个request并且开始等待。(可以解决等待)
    • 2) 往connRequest插入自己的号牌,插入号码牌之后这边就不需要阻塞等待继续往下
    • 3) Context取消操作时,从connRequest这个map取走自己的号码牌
// conn returns a newly-opened or cached *driverConn.
func (db *DB) conn(ctx context.Context, strategy connReuseStrategy) (*driverConn, error) {
    // 先判断db是否已经关闭。
    db.mu.Lock()
    if db.closed {
        db.mu.Unlock()
        return nil, errDBClosed
    }
    // 注意检测context是否已经被超时等原因被取消。
    select {
    default:
    case <-ctx.Done():
        db.mu.Unlock()
        return nil, ctx.Err()
    }
    lifetime := db.maxLifetime

    // 这边如果在freeConn这个切片有空闲连接的话,就left pop一个出列。注意的是,这边因为是切片操作,所以需要前面需要加锁且获取后进行解锁操作。同时判断返回的连接是否已经过期。
    numFree := len(db.freeConn)
    if strategy == cachedOrNewConn && numFree > 0 {
        conn := db.freeConn[0]
        copy(db.freeConn, db.freeConn[1:])
        db.freeConn = db.freeConn[:numFree-1]
        conn.inUse = true
        db.mu.Unlock()
        if conn.expired(lifetime) {
            conn.Close()
            return nil, driver.ErrBadConn
        }
        // Lock around reading lastErr to ensure the session resetter finished.
        conn.Lock()
        err := conn.lastErr
        conn.Unlock()
        if err == driver.ErrBadConn {
            conn.Close()
            return nil, driver.ErrBadConn
        }
        return conn, nil
    }

    // 这边就是等候获取连接的重点了。当空闲的连接为空的时候,这边将会新建一个request(的等待连接 的请求)并且开始等待
    if db.maxOpen > 0 && db.numOpen >= db.maxOpen {
        // 下面的动作相当于往connRequests这个map插入自己的号码牌。
        // 插入号码牌之后这边就不需要阻塞等待继续往下走逻辑。
        req := make(chan connRequest, 1)
        reqKey := db.nextRequestKeyLocked()
        db.connRequests[reqKey] = req
        db.waitCount++
        db.mu.Unlock()

        waitStart := time.Now()

        // Timeout the connection request with the context.
        select {
        case <-ctx.Done():
            // context取消操作的时候,记得从connRequests这个map取走自己的号码牌。
            db.mu.Lock()
            delete(db.connRequests, reqKey)
            db.mu.Unlock()

            atomic.AddInt64(&db.waitDuration, int64(time.Since(waitStart)))

            select {
            default:
            case ret, ok := <-req:
 // 这边值得注意了,因为现在已经被context取消了。但是刚刚放了自己的号码牌进去排队里面。意思是说不定已经发了连接了,所以得注意归还!
                if ok && ret.conn != nil {
                    db.putConn(ret.conn, ret.err, false)
                }
            }
            return nil, ctx.Err()
        case ret, ok := <-req:
            // 下面是已经获得连接后的操作了。检测一下获得连接的状况。因为有可能已经过期了等等。
            atomic.AddInt64(&db.waitDuration, int64(time.Since(waitStart)))

            if !ok {  return nil, errDBClosed }
            if ret.err == nil && ret.conn.expired(lifetime) {
                ret.conn.Close()
                return nil, driver.ErrBadConn
            }
            if ret.conn == nil {
                return nil, ret.err
            }
            ret.conn.Lock()
            err := ret.conn.lastErr
            ret.conn.Unlock()
            if err == driver.ErrBadConn {
                ret.conn.Close()
                return nil, driver.ErrBadConn
            }
            return ret.conn, ret.err
        }
    }
    // 下面就是如果上面说的限制情况不存在,可以创建先连接时候,要做的创建连接操作了。
    db.numOpen++ // optimistically
    db.mu.Unlock()
    ci, err := db.connector.Connect(ctx)
    if err != nil {
        db.mu.Lock()
        db.numOpen-- // correct for earlier optimism
        db.maybeOpenNewConnections()
        db.mu.Unlock()
        return nil, err
    }
    db.mu.Lock()
    dc := &driverConn{
        db:        db,
        createdAt: nowFunc(),
        ci:        ci,
        inUse:     true,
    }
    db.addDepLocked(dc, dc)
    db.mu.Unlock()
    return dc, nil
}

4. 释放连接:

返回连接,如果有在排队轮换的请求就

  • 判断特殊边界:比如已关闭,numOpen大于maxOpen
  • 从connRequests这个map里面随机抽一个在排队的其请求,取出后还给源节点,不需要归还池子。
func (db *DB) putConnDBLocked(dc *driverConn, err error) bool {
	if db.closed {
		return false
	}
	if db.maxOpen > 0 && db.numOpen > db.maxOpen {
		return false
	}
	if c := len(db.connRequests); c > 0 {
		var req chan connRequest
		var reqKey uint64
		for reqKey, req = range db.connRequests {
			break
		}
		delete(db.connRequests, reqKey) // Remove from pending requests.
		if err == nil {
			dc.inUse = true
		}
                //连接给这个正常排队的连接。
		req <- connRequest{
			conn: dc,
			err:  err,
		}
		return true
	} else if err == nil && !db.closed {
		if db.maxIdleConnsLocked() > len(db.freeConn) {
			db.freeConn = append(db.freeConn, dc)
			db.startCleanerLocked()
			return true
		}
		db.maxIdleClosed++
	}
	return false
}

解决重要问题:

  1. 解决too many connection : 避免连接太多的情况,主要就是靠--》 connRequst 连接请求队列的机制
  2. 连接健康问题检查
    1. 获得连接前: 即连接是否已关闭,是否超时(超过context或db的maxLifeTime) 具体是用conn.expired方法 | db.Closed关闭状态
    2. 获得连接后超时: atomic.AddInt64(&db.waitDuration, int64(time.Since(waitStart))) 关闭状态:ret, ok <-req , errDBClosed

网络连接线程池

基础原理

服务端启动,先按固定大小预创建多个线程,当有新连接建立时,往连接字队列里设置这个新描述字,线程池里的线程负责从连接队列取出连接描述字

image.png

  • Cond: 条件变量,多个线程需要交互下,用来线程间同步。
  • Mutex:互斥锁
// 定义一个队列
typedef struct {
    int number;  // 队列里的描述字最大个数
    int *fd;     // 这是一个数组指针
    int front;   // 当前队列的头位置
    int rear;    // 当前队列的尾位置
    pthread_mutex_t mutex;  // 锁
    pthread_cond_t cond;    // 条件变量
} block_queue;
 // 初始化队列
void block_queue_init(block_queue *blockQueue, int number) {
    blockQueue->number = number;
    blockQueue->fd = calloc(number, sizeof(int));
    blockQueue->front = blockQueue->rear = 0;
    pthread_mutex_init(&blockQueue->mutex, NULL);
    pthread_cond_init(&blockQueue->cond, NULL);
}
 // 往队列里放置一个描述字 fd
void block_queue_push(block_queue *blockQueue, int fd) {
    // 一定要先加锁,因为有多个线程需要读写队列
    pthread_mutex_lock(&blockQueue->mutex);
    // 将描述字放到队列尾的位置
    blockQueue->fd[blockQueue->rear] = fd;
    // 如果已经到最后,重置尾的位置
    if (++blockQueue->rear == blockQueue->number) {
        blockQueue->rear = 0;
    }
    printf("push fd %d", fd);
    // 通知其他等待读的线程,有新的连接字等待处理
    pthread_cond_signal(&blockQueue->cond);
    // 解锁
    pthread_mutex_unlock(&blockQueue->mutex);
}
// 从队列里读出描述字进行处理
int block_queue_pop(block_queue *blockQueue) {
    // 加锁
    pthread_mutex_lock(&blockQueue->mutex);
    // 判断队列里没有新的连接字可以处理,就一直条件等待,直到有新的连接字入队列
    while (blockQueue->front == blockQueue->rear)
        pthread_cond_wait(&blockQueue->cond, &blockQueue->mutex);
    // 取出队列头的连接字
    int fd = blockQueue->fd[blockQueue->front];
    // 如果已经到最后,重置头的位置
    if (++blockQueue->front == blockQueue->number) {
        blockQueue->front = 0;
    }
    printf("pop fd %d", fd);
    // 解锁
    pthread_mutex_unlock(&blockQueue->mutex);
    // 返回连接字
    return fd;
}

服务端使用线程池技术:

int main(int c, char **v) {
    int listener_fd = tcp_server_listen(SERV_PORT);
    block_queue blockQueue;
    block_queue_init(&blockQueue, BLOCK_QUEUE_SIZE);
    thread_array = calloc(THREAD_NUMBER, sizeof(Thread));
    int i;
    for (i = 0; i < THREAD_NUMBER; i++) {
        pthread_create(&(thread_array[i].thread_tid), NULL, &thread_run, (void *) &blockQueue);
    }
 
    while (1) {
        struct sockaddr_storage ss;
        socklen_t slen = sizeof(ss);
        int fd = accept(listener_fd, (struct sockaddr *) &ss, &slen);
//....
    }
 
    return 0;
}

主程序变得非常简洁。

  • 预创建了多个线程,组成了一个线程池。
  • 新连接建立后,将连接描述字加入到队列中。
  • 工作线程的功能:从连接队列中取出描述符,并进行服务处理

TCP workerpool实现

goroutine is cheap, 通常可以每个传入的请求生成一个goroutine。

理由

  1. 但规模上,非零成本可能为瓶颈。 或者你可能想限制服务器的并发性
  2. 因为并非所有机器都是平等的。

这两种情况下,工作池,是下一个合乎逻辑的步骤 -- 可以通过创建并发工作池来分摊goroutine成本。

工作池

由于工作池重用 Goroutines,则内存的扩张也可以得到缓解,每个Goroutine会自己维护4K左右的栈内存。

// workerResetThreshold defines how often the stack must be reset. Every N
// requests, by spawning a new Goroutine in its place, a worker can reset its
// stack so that large stacks don't live in memory forever. 2^16 should allow
// each Goroutine stack to live for at least a few seconds in a typical
// workload (assuming a QPS of a few thousand requests/sec).
const workerResetThreshold = 1 << 16
type request struct {
    // whatever
}
type server struct {
    workerChannels []chan *request
}

func (s *server) worker(ch chan *request) {
    // To make sure all server workers don't reset at the same time, choose a
    // random number of iterations before resetting.
    threshold := workerResetThreshold + rand.Intn(workerResetThreshold)
    for completed := 0; completed < threshold; completed++ {
        req, ok := <-ch
        if !ok {
            return
        }
        s.handleSingleRequest(req)
    }
    // Restart in a new Goroutine.
    go s.worker(ch)
}

func (s *server) initWorkers(numWorkers int) {
    s.workerChannels = make([]chan *request, numWorkers)
    for i := 0; i < numWorkers; i++ {
        // One channel per worker reduces contention.
        s.workerChannels[i] = make(chan *request)
        go s.worker(s.workerChannels[i])
    }
}

func (s *server) stopWorkers() {
    for _, ch := range s.workerChannels {
        close(ch)
    }
}

func (s *server) handleSingleRequest(req *request) {
    log.Printf("processing req=%v\n", req)
}

func (s *server) listenAndHandleForever() {
    for counter := 0; ; counter++ {
        req := listenForRequest()
        select {
        case s.workerChannels[counter % len(s.workerChannels)] <- req:
        default:
            // TODO: If this workers is busy, fall back to spawning a Goroutine. Or
            // find a different worker. Or dynamically increase the number of workers.
            // Or just reject the request.
        }
    }
}
func newServer() *server {
    s := &server{}
    s.initWorkers(16)
    return s
}
func main() {
    s := newServer()
    s.listenAndHandleForever()
}

Reference

github.com/fatih/pool/…

learnku.com/articles/41…

juejin.cn/post/684490…

database/sql