1. 简介

对于一个MySQL实例,一般来说随着并发连接数的增长,实例的总性能会提升。但当并发数超过一定数量时,实例总性能会随着连接数继续上涨而降低。性能降低的原因主要在于两点:

  • MySQL对于每一个连接请求会创建一个线程,随着线程数的上升,会导致频繁的context switch,并导致CPU cache命中率降低;
  • 大量的线程会导致共享资源争用加剧,锁获取会成为性能瓶颈;

为了解决这个问题,MySQL官方在5.5.16企业版中发布了线程池插件,改变了连接处理模型,由以前的一个连接对应一个线程变为由一个线程数上限固定的线程池共同处理所有连接,但并未开源代码。

MariaDB在其5.1版本中实现了线程池功能,但在5.5中重新将其实现,并在行为上和MySQL官方几乎一致。Percona先是在其5.5版本中移植了MariaDB 5.5版本中的线程池,然后在5.5到5.7版本迭代中做了一些在调度优先级上的优化。MariaDB在10.2中实现了和Percona 5.7中类似的调度优先级上的优化。这里主要介绍Percona 5.7的线程池实现。

2. threadpool启用

线程池默认是关闭的,要开启这个功能,需要启动实例时指定参数--thread-handling=pool-of-threads。这个参数在代码中对应的变量是Connection_handler_manager::thread_handling,其中Connection_handler_manager是一个singleton类,thread_handling是其静态成员变量。Connection_handler_manager是代码中连接模型的抽象,当实例启动后,会有一条线程专门监听指定的TCP端口和Unix Socket上是否有新的连接请求,这部分代码实现在mysqld_main --> Connection_acceptor->connection_event_loop()中,当有连接请求时会调用Connection_handler_manager中相应Connection_handler对象的add_connection()虚方法处理连接,线程池和之前的thread-per-connection模型在这里就会进入不同的代码路径,因为启用线程池时,Connection_handler_manager::handlerThread_pool_connection_handler对象,而thread-per-connection模型时该成员为Per_thread_connection_handler对象。Connection_handler_manager::handler的确定是在函数Connection_handler_manager::init中,根据Connection_handler_manager::thread_handling决定构造哪个类的对象。

相关的代码调用为:

Connection_handler_manager::init()
{
  switch (Connection_handler_manager::thread_handling)
  {
  case SCHEDULER_ONE_THREAD_PER_CONNECTION:
    connection_handler= new (std::nothrow) Per_thread_connection_handler();
    break;
  case SCHEDULER_NO_THREADS:
    connection_handler= new (std::nothrow) One_thread_connection_handler();
    break;
  case SCHEDULER_THREAD_POOL:
    connection_handler= new (std::nothrow) Thread_pool_connection_handler();
    break;
  default:
    DBUG_ASSERT(false);
  }
}

connection_event_loop --> listen_for_connection_event
                      |__ process_new_connection --> handler->add_connection

在介绍线程池实现之前,先简单介绍下thread-per-connection实现。当监听到一个新连接时,MySQL会首先检查是否存在之前连接退出后留下来的idle线程,如果有,将当前连接的相关信息存在该线程的一个链表中,然后唤醒该线程,该线程被唤醒后会从链表中取出连接的相关信息并且处理认证和接下来的查询;如果没有idle线程,则创建一条新的线程;当连接结束时,线程并不会立刻退出,而是会检查当前idle线程数是否超过系统变量thread_cache_size,如果超过则当前线程对出,否则进入睡眠等待处理新的连接。

相关的代码调用为:

Per_thread_connection_handler::add_connection --> check_idle_thread_and_enqueue_connection --> waiting_channel_info_list->push_back()
                                              |                                            |__ wake_pthread++
                                              |                                            |__ mysql_cond_signal(&COND_thread_cache)
                                              |__ mysql_thread_create(handle_connection)

handle_connection --> for loop --> thd_prepare_connection --> login_connection //do authentication
                               |__ while(thd_connection_alive) --> do_command
                               |__ close_connection
                               |__ block_until_new_connection

值得注意的是,当线程池启用后,默认TCP端口和Unix socket所有连接都走线程池的代码路径,但通过连接到系统参数extra_port指定的端口,依然可以让该连接走之前thread-per-connection的代码路径。

3. threadpool实现

3.1 整体架构

线程池由多个thread_group组成,thread_group数量由系统参数thread_pool_size决定,每条连接根据其THD::thread_id()决定其应该去到哪一个thread_group中(每个连接依然有一个单独的THD)。每个thread_group由一组动态的线程构成,包括listener线程和worker线程,负责处理这个thread_group内所有连接的查询请求。全局还有一条timer线程。

相关的结构包括:

struct connection_t //每个连接
{
  THD *thd;
  thread_group_t *thread_group; //所属的thread_group
  ulonglong abs_wait_timeout;  //连接退出的时间,由@@session.wait_timeout计算出
  bool logged_in; //是否初次登录
  uint tickets; //优先级调度使用
  bool dump_thread; //连接是否在执行COM_BINLOG_DUMP或COM_BINLOG_DUMP_GTID,对着两个类型特定优化时使用
};

struct thread_group_t
{
  mysql_mutex_t mutex;
  connection_queue_t queue; //工作队列
  connection_queue_t high_prio_queue; //高优先级工作队列
  worker_list_t waiting_threads; //空闲的线程
  worker_thread_t *listener; //当前group里的listener线程
  int pollfd; //用于listener线程监听group内连接是否有新查询到来
  int thread_count; //组内所有线程数
  int active_thread_count; //组内活跃线程数,listener线程不算在内,线程在执行过程中等待锁或网络IO时也不计算在内
  int connection_count; //连接数
  int waiting_thread_count; //在等待锁或者网络IO的线程数
  int dump_thread_count; //在执行COM_BINLOG_DUMP或COM_BINLOG_DUMP_GTID的线程数
  /* Stats for the deadlock detection timer routine.*/
  int io_event_count; //listener线程获取到事件数
  int queue_event_count; //worker线程处理工作队列中事件数
  bool stalled; //当前group是否拥挤
} MY_ALIGNED(512);

3.2 功能线程

  • 监听线程
    上面已经介绍过,该线程主要监听TCP端口和Unix socket上是否有新的连接,如有则构造THD对象和connection_t结构,并确定连接所属的thread_group,然后调用queue_put将当前connection_t加入到thread_group的工作队列(thread_group.queue)中。相关函数调用为:
    Thread_pool_connection_handler::add_connection --> Channel_info::create_thd |__ allocate_connection |__ decide connection->thread_group |__ queue_put --> thread_group->queue.push_back()
  • worker线程
    worker线程循环地通过get_event获取事件,再通过handle_event处理事件。获取事件时,会先调用queue_get从工作队列和优先工作队列中获取,如果没有则再调用io_poll_wait检查当前group内所有socket是否可读,若还是没有,则当前worker线程睡眠;在获取到事件后,通过判断connection_t.logged_in决定是对该连接调用threadpool_add_connection做身份认证和初始化,还是调用threadpool_process_request处理SQL查询;当连接退出时,worker线程会获取到事件并进入threadpool_process_request,但该函数会返回1,于是在handle_event中会调用abort_connection退出连接。
    threadpool_process_request中,会循环调用do_command处理当前连接的SQL查询。在do_command中,会将NET.read_timeout设置为0,也就是说不同于thread-per-connection,当do_command读取不到查询请求时,worker线程并不会阻塞在get_command上等待SQL查询的到来;在这种情况下,从do_command返回后会直接返回到worker_main中,并调用get_event获取下一个事件。所以可以看出,一条线程会持续处理一个连接上的请求,直到没有新的SQL查询,然后返回去处理其他连接。worker线程的入口函数为worker_main
    相关的函数调用为:
    handle_event --> threadpool_add_connection --> thd_prepare_connection --> login_connection --> check_connection --> acl_authenticate | |__ prepare_new_connection_state |__ threadpool_process_request --> do_command |__ connection_abort --> threadpool_remove_connection --> end_connection |__ close_connection |__ remove_thd
  • listener线程
    从逻辑上来看,listener线程很简单,循环的调用io_poll_wait,监听当前group内所有socket是否可读,如果有新的事件到来,则添加到工作队列中,供worker线程读取,然后再调用io_poll_wait。listener线程和worker线程逻辑上是一个很简单的生产者消费者模型,但在实现上,为了减少线程的切换,listener线程在监听到事件之后可能会选择自己处理这个事件,这种情况下,listener线程就转变为了worker线程。而worker线程在get_event中如果发现工作队列中没有事件,并且当前group不存在listener线程(listener线程转变为了worker线程),那么它将会转变为listener线程并调用函数listener监听当前group内的socket。所以,listener线程和worker线程的身份并不是固定的,会动态的根据当前组内状态发生切换。
  • timer线程
    线程池中的所有thread_group共享一条timer线程,timer线程入口函数为timer_thread,在线程池初始化函数tp_init中通过start_timer被创建。timer线程主要是循环地检查所有thread_group是否存在拥挤,以及检查所有连接是否空闲超时。
    拥挤检测实现在函数check_stall中,通过检查每个group内listener线程是否在这个时间片获取了事件,以及每个group内worker线程是否在这个时间片内处理了工作队列内的事件,判断group是否处于拥挤状态,如果是,则调用wake_or_create_thread唤醒空闲的worker线程,或者创建新的worker线程(如果组内总线程数少于系统变量thread_pool_max_threads)。
    连接空闲超时检测实现在函数timeout_check中。这里为了保证timer线程能及时的醒来并检测连接超时,worker线程在handle_event中处理完查询请求后会调用set_wait_timeout设置timer线程的next_timeout_check,计算依据是当前连接的@@session.wait_timeout。每次timer线程被唤醒后,都会检测当前时间是否超过了next_timeout_check,如果是,则表明有连接需要检测是否超时了。timer线程每次睡眠的时间由系统参数thread_pool_stall_limit控制。

3.4 worker线程睡眠

当worker线程在get_event中没有获取到事件时,并且当前group中已经有listener线程时,worker线程会将自己加入到waiting_threads链表中并睡眠,如果thread_pool_idle_thread_timeout后还没有被唤醒处理事件,该worker线程会退出。

3.5 线程池扩张与收缩

  • 线程池扩张主要实现在函数wake_or_create_thread中,调用该函数的地方主要为:
    • timer线程在check_stall中发现当前group没有listener线程,并且当前时间片中没有从socket获取过事件,则激活或创建新的线程;
    • timer线程在check_stall中发现当前工作队列不为空,且当前时间片中worker线程没有从工作队列获取事件,则激活或创建新的线程;
    • 连接监听线程在将事件放入工作队列后检查当前group的active_thread_count是否为0,若是则激活或创建新线程;
    • listener线程在从socket获取到事件后,检查当前group的active_thread_count是否为0,若是则激活或创建新线程;
    • worker线程在执行查询过程中等待锁或网络IO前若发现当前group的active_thread_count为0,则激活或创建新线程;这个逻辑percona经过测试认为对性能提升没有太大作用,所以在编译时可以通过宏控制是否包含这部分代码;
  • 线程池的收缩主要通过上面提到的worker线程睡眠超时退出实现;

4. threadpool优化

4.1 优先调度

Percona在移植了MariaDB线程池代码后发现很容易出现死锁,原因在于线程池是对查询执行做了限制,查询会存在需要在线程池等待处理的状态,如果正在执行的连接被一个锁阻塞,而持有这个锁额连接又在线程池中等待执行,则在线程池满载的情况下,会导致死锁,因为持有锁的连接得不到执行的机会来释放锁。

为了缓解这个问题,Percona增加了一个高优先级工作队列high_prio_queue,worker线程在获取事件时优先从high_prio_queue中拿;listener线程在存放从socket获取的事件时,判断该连接的状态是否为启动了一个显式事务,如果当前事件处于一个显式事务中,则将该事件放入高优先级队列;这样做的逻辑是可以使已经开始的事务尽快地结束,进而可以释放事务持有的锁,减小死锁产生概率。

4.2 低优先队列限制

这个优化还是为了减少上述提到的死锁,在之前的实现中,当worker线程在等待锁或网络IO时会将active_thread_count—,所以就更大可能会导致新的线程的产生,如果这时候高优先级队列为空,则这些线程会从低优先级队列获取事件,导致group内同时存在的线程数上升,如果释放锁的连接能够得到机会执行,那么会导致group内同时active的线程数很大,导致性能抖动;如果释放锁的连接不能得到机会执行(thread_pool_max_threads被达到),那么就出现死锁。Percona解决这个问题的方法是,当worker线程要等待锁或IO时,将active_thread_count—,并且将waiting_thread_count++,在queue_get中,如果active_thread_count + waiting_thread_count超过thread_pool_over_subscribe + 1,则不从低优先级队列获取事件,即使该队列不为空。这样的逻辑同4.1一样,尽快使高优先级队列中的事务结束并释放锁。

4.3 LOCK TABLES优化

对于执行了LOCK TABLES语句的连接,后续的所有事件即使不在显式事务中也放入高优先级队列,逻辑同上。

4.4 COM_BINGLOG_DUMP优化

这个优化是AliSQL那边实现的。对于COM_BINLOG_DUMP和COM_BINLOG_DUMP_GTID这两类预期会执行很长时间的命令,他们会长时间占用thread_group中active_thread_count的一个份额,导致线程池处理能力下降,所以将这两类dump任务识别出来并且不计入active_thread_count。

5. 总结

本文介绍了Percona 5.7中线程池的实现及相关优化。

文章来源于腾讯云开发者社区,点击查看原文