線程池是Mysql5.6的一個核心功能,對於伺服器應用而言,無論是web應用服務還是DB服務,高並發請求始終是一個繞不開的話題。當有大量請求並發訪問時,一定伴隨著資源的不斷建立和釋放,導致資源使用率低,降低了服務品質。線程池是一種通用的技術,通過預先建立一定數量的線程,當有請求達到時,線程池分配一個線程提供服務,請求結束後,該線程又去服務其他請求。 通過這種方式,避免了線程和記憶體對象的頻繁建立和釋放,降低了服務端的並發度,減少了環境切換和資源的競爭,提高資源利用效率。所有服務的線程池本質都是位了提高資源利用效率,並且實現方式也大體相同。本文主要說明Mysql線程池的實現原理。
在Mysql5.6出現以前,Mysql處理串連的方式是One-Connection-Per-Thread,即對於每一個資料庫連接,Mysql-Server都會建立一個獨立的線程服務,請求結束後,銷毀線程。再來一個串連請求,則再建立一個串連,結束後再進行銷毀。這種方式在高並發情況下,會導致線程的頻繁建立和釋放。當然,通過thread-cache,我們可以將線程緩衝起來,以供下次使用,避免頻繁建立和釋放的問題,但是無法解決高串連數的問題。One-Connection-Per-Thread方式隨著串連數暴增,導致需要建立同樣多的服務線程,高並發線程意味著高的記憶體消耗,更多的環境切換(cpu cache命中率降低)以及更多的資源競爭,導致服務出現抖動。相對於One-Thread-Per-Connection方式,一個線程對應一個串連,Thread-Pool實現方式中,線程處理的最小單位是statement(語句),一個線程可以處理多個串連的請求。這樣,在保證充分利用硬體資源情況下(合理設定線程池大小),可以避免瞬間串連數暴增導致的伺服器抖動。
調度方式實現
Mysql-Server同時支援3種串連管理方式,包括No-Threads,One-Thread-Per-Connection和Pool-Threads。No-Threads表示處理串連使用主線程處理,不額外建立線程,這種方式主要用於調試;One-Thread-Per-Connection是線程池出現以前最常用的方式,為每一個串連建立一個線程服務;Pool-Threads則是本文所討論的線程池方式。Mysql-Server通過一組函數指標來同時支援3種串連管理方式,對於特定的方式,將函數指標設定成特定的回呼函數,串連管理方式通過thread_handling參數控制,代碼如下:
if (thread_handling <= SCHEDULER_ONE_THREAD_PER_CONNECTION) one_thread_per_connection_scheduler(thread_scheduler, &max_connections, &connection_count);else if (thread_handling == SCHEDULER_NO_THREADS) one_thread_scheduler(thread_scheduler);else pool_of_threads_scheduler(thread_scheduler, &max_connections,&connection_count);
串連管理流程
通過poll監聽mysql連接埠的串連請求
收到串連後,調用accept介面,建立通訊socket
初始化thd執行個體,vio對象等
根據thread_handling方式設定,初始化thd執行個體的scheduler函數指標
調用scheduler特定的add_connection函數建立串連
下面代碼展示了scheduler_functions模板和線程池對模板回呼函數的實現,這個是多種串連管理的核心。
struct scheduler_functions { uint max_threads; uint *connection_count; ulong *max_connections; bool (*init)(void); bool (*init_new_connection_thread)(void); void (*add_connection)(THD *thd); void (*thd_wait_begin)(THD *thd, int wait_type); void (*thd_wait_end)(THD *thd); void (*post_kill_notification)(THD *thd); bool (*end_thread)(THD *thd, bool cache_thread); void (*end)(void);};static scheduler_functions tp_scheduler_functions= { 0, // max_threadsNULL,NULL, tp_init, // initNULL, // init_new_connection_threadtp_add_connection, // add_connectiontp_wait_begin, // thd_wait_begin tp_wait_end, // thd_wait_endtp_post_kill_notification, // post_kill_notification NULL, // end_threadtp_end // end };
線程池的相關參數
- thread_handling:表示線程池模型。
- thread_pool_size:表示線程池的group個數,一般設定為當前CPU核心數目。理想情況下,一個group一個活躍的背景工作執行緒,達到充分利用CPU的目的。
- thread_pool_stall_limit:用於timer線程定期檢查group是否“停滯”,參數表示檢測的間隔。
- thread_pool_idle_timeout:當一個worker空閑一段時間後會自動結束,保證線程池中的背景工作執行緒在滿足請求的情況下,保持比較低的水平。
- thread_pool_oversubscribe:該參數用於控制CPU核心上“超頻”的線程數。這個參數設定值不含listen執行緒計數。
- threadpool_high_prio_mode:表示優先隊列的模式。
線程池實現
上面描述了Mysql-Server如何管理串連,這節重點描述線程池的實現架構,以及關鍵介面。如圖1
每一個綠色的方框代表一個group,group數目由thread_pool_size參數決定。每個group包含一個優先隊列和普通隊列,包含一個listener線程和若干個背景工作執行緒,listener線程和worker線程可以動態轉換,worker線程數目由工作負載決定,同時受到thread_pool_oversubscribe設定影響。此外,整個線程池有一個timer線程監控group,防止group“停滯”。
關鍵介面
1. tp_add_connection[處理新串連]
1) 建立一個connection對象
2) 根據thread_id%group_count確定connection分配到哪個group
3) 將connection放進對應group的隊列
4) 如果當前活躍線程數為0,則建立一個背景工作執行緒
2. worker_main[背景工作執行緒]
1) 調用get_event擷取請求
2) 如果存在請求,則調用handle_event進行處理
3) 否則,表示隊列中已經沒有請求,退出結束。
3. get_event[擷取請求]
1) 擷取一個串連請求
2) 如果存在,則立即返回,結束
3) 若此時group內沒有listener,則線程轉換為listener線程,阻塞等待
4) 若存在listener,則將線程加入等待隊列頭部
5) 線程休眠指定的時間(thread_pool_idle_timeout)
6) 如果依然沒有被喚醒,是逾時,則線程結束,結束退出
7) 否則,表示隊列裡有串連請求到來,跳轉1
備忘:擷取串連請求前,會判斷當前的活躍線程數是否超過了
thread_pool_oversubscribe+1,若超過了,則將線程進入休眠狀態。
4. handle_event[處理請求]
1) 判斷串連是否進行登入驗證,若沒有,則進行登入驗證
2) 關聯thd執行個體資訊
3) 擷取網路資料包,分析請求
4) 調用do_command函數迴圈處理請求
5) 擷取thd執行個體的通訊端控制代碼,判斷控制代碼是否在epoll的監聽列表中
6) 若沒有,調用epoll_ctl進行關聯
7) 結束
5.listener[監聽線程]
1) 調用epoll_wait進行對group關聯的通訊端監聽,阻塞等待
2) 若請求到來,從阻塞中恢複
3) 根據串連的優先順序別,確定是放入普通隊列還是優先隊列
4) 判斷隊列中任務是否為空白
5) 若隊列為空白,則listener轉換為worker線程
6) 若group內沒有活躍線程,則喚醒一個線程
備忘:這裡epoll_wait監聽group內所有串連的通訊端,然後將監聽到的串連
請求push到隊列,worker線程從隊列中擷取任務,然後執行。
6. timer_thread[監控線程]
1) 若沒有listener線程,並且最近沒有io_event事件
2) 則建立一個喚醒或建立一個背景工作執行緒
3) 若group最近一段時間沒有處理請求,並且隊列裡面有請求,則
4) 表示group已經stall,則喚醒或建立線程
5)檢查是否有連線逾時
備忘:timer線程通過調用check_stall判斷group是否處於stall狀態,通過調用timeout_check檢查用戶端串連是否逾時。
7.tp_wait_begin[進入等待狀態流程]
1) active_thread_count減1,waiting_thread_count加1
2)設定connection->waiting= true
3) 若活躍線程數為0,並且任務隊列不為空白,或者沒有監聽線程,則
4) 喚醒或建立一個線程
8.tp_wait_end[結束等待狀態流程]
1) 設定connection的waiting狀態為false
2) active_thread_count加1,waiting_thread_count減1
備忘:
1)waiting_threads這個list裡面的線程是空閑線程,並非等待線程,所謂空閑線程是隨時可以處理任務的線程,而等待線程則是因為等待鎖,或等待io操作等無法處理任務的線程。
2)tp_wait_begin和tp_wait_end的主要作用是由於彙報狀態,即使更新active_thread_count和waiting_thread_count的資訊。
9. tp_init/tp_end
分別調用thread_group_init和thread_group_close來初始化和銷毀線程池
線程池與串連池
串連池通常實現在Client端,是指應用(用戶端)建立預先建立一定的串連,利用這些串連服務於用戶端所有的DB請求。如果某一個時刻,閒置串連數小於DB的請求數,則需要將請求排隊,等待空閑串連處理。通過串連池可以複用串連,避免串連的頻繁建立和釋放,從而減少請求的平均回應時間,並且在請求繁忙時,通過請求排隊,可以緩衝應用對DB的衝擊。線程池實現在server端,通過建立一定數量的線程服務DB請求,相對於one-conection-per-thread的一個線程服務一個串連的方式,線程池服務的最小單位是語句,即一個線程可以對應多個活躍的串連。通過線程池,可以將server端的服務線程數控制在一定的範圍,減少了系統資源的競爭和線程環境切換帶來的消耗,同時也避免出現高串連數導致的高並發問題。串連池和線程池相輔相成,通過串連池可以減少串連的建立和釋放,提高請求的平均回應時間,並能很好地控制一個應用的DB串連數,但無法控制整個應用叢集的串連數規模,從而導致高串連數,通過線程池則可以很好地應對高串連數,保證server端能提供穩定的服務。如圖2所示,每個web-server端維護了3個串連的串連池,對於串連池的每個串連實際不是獨佔db-server的一個worker,而是可能與其他串連共用。這裡假設db-server只有3個group,每個group只有一個worker,每個worker處理了2個串連的請求。
線程池最佳化
1.調度死結解決
引入線程池解決了多線程高並發的問題,但也帶來一個隱患。假設,A,B兩個事務被分配到不同的group中執行,A事務已經開始,並且持有鎖,但由於A所在的group比較繁忙,導致A執行一條語句後,不能立即獲得調度執行;而B事務依賴A事務釋放鎖資源,雖然B事務可以被調度起來,但由於無法獲得鎖資源,導致仍然需要等待,這就是所謂的調度死結。由於一個group會同時處理多個串連,但多個串連不是對等的。比如,有的串連是第一次發送請求;而有的串連對應的事務已經開啟,並且持有了部分鎖資源。為了減少鎖資源爭用,後者顯然應該比前者優先處理,以達到儘早釋放鎖資源的目的。因此在group裡面,可以添加一個優先順序隊列,將已經持有鎖的串連,或者已經開啟的事務的串連發起的請求放入優先隊列,背景工作執行緒首先從優先隊列擷取任務執行。
2.大查詢處理
假設一種情境,某個group裡面的串連都是大查詢,那麼group裡面的背景工作執行緒數很快就會達到thread_pool_oversubscribe參數設定值,對於後續的串連請求,則會響應不及時(沒有更多的串連來處理),這時候group就發生了stall。通過前面分析知道,timer線程會定期檢查這種情況,並建立一個新的worker線程來處理請求。如果長查詢來源於業務請求,則此時所有group都面臨這種問題,此時主機可能會由於負載過大,導致hang住的情況。這種情況線程池本身無能為力,因為源頭可能是爛SQL並發,或者SQL沒有走對執行計畫導致,通過其他方法,比如SQL高低水位限流或者SQL過濾手段可以應急處理。但是,還有另外一種情況,就是dump任務。很多下遊依賴於資料庫的未經處理資料,通常通過dump命令將資料拉到下遊,而這種dump任務通常都是耗時比較長,所以也可以認為是大查詢。如果dump任務集中在一個group內,並導致其他正常業務請求無法立即響應,這個是不能容忍的,因為此時資料庫並沒有壓力,只是因為採用了線程池策略,才導致了請求響應不及時,為瞭解決這個問題,我們將group中處理dump任務的線程不計入thread_pool_oversubscribe累計值,避免上述問題。
one-connection-per-thread
根據scheduler_functions的模板,我們也可以列出one-connection-per-thread方式的幾個關鍵函數。
static scheduler_functions con_per_functions= { max_connection+1, // max_threads NULL, NULL, NULL, // init Init_new_connection_handler_thread, // init_new_connection_thread create_thread_to_handle_connection, // add_connection NULL, // thd_wait_begin NULL, // thd_wait_end NULL, // post_kill_notification one_thread_per_connection_end, // end_thread NULL // end };
1.init_new_connection_handler_thread
這個介面比較簡單,主要是調用pthread_detach,將線程設定為detach狀態,線程結束後自動釋放所有資源。
2.create_thread_to_handle_connection
這個介面是處理新串連的介面,對於線程池而言,會從thread_id%group_size對應的group中擷取一個線程來處理,而one-connection-per-thread方式則會判斷是否有thread_cache可以使用,如果沒有則建立線程來處理。具體邏輯如下:
(1).判斷緩衝的線程數是否使用完(比較blocked_pthread_count 和wake_pthread大小)
(2).若還有緩衝線程,將thd加入waiting_thd_list的隊列,喚醒一個等待COND_thread_cache的線程
(3).若沒有,建立一個新的線程處理,線程的入口函數是do_handle_one_connection
(4).調用add_global_thread加入thd數組。
3.do_handle_one_connection
這個介面被create_thread_to_handle_connection調用,處理請求的主要實現介面。
(1).迴圈調用do_command,從socket中讀取網路包,並且解析執行;
(2). 當遠程用戶端發送關閉串連COMMAND(比如COM_QUIT,COM_SHUTDOWN)時,退出迴圈
(3).調用close_connection關閉串連(thd->disconnect());
(4).調用one_thread_per_connection_end函數,確認是否可以複用線程
(5).根據返回結果,確定退出背景工作執行緒還是繼續迴圈執行命令。
4.one_thread_per_connection_end
判斷是否可以複用線程(thread_cache)的主要函數,邏輯如下:
(1).調用remove_global_thread,移除線程對應的thd執行個體
(2).調用block_until_new_connection判斷是否可以重用thread
(3).判斷緩衝的線程是否超過閥值,若沒有,則blocked_pthread_count++;
(4).阻塞等待條件變數COND_thread_cache
(5).被喚醒後,表示有新的thd需要重用線程,將thd從waiting_thd_list中移除,使用thd初始化線程的thd->thread_stack
(6).調用add_global_thread加入thd數組。
(7).如果可以重用,返回false,否則返回ture
線程池與epoll
在引入線程池之前,server層只有一個監聽線程,負責監聽mysql連接埠和本地unixsocket的請求,對於每個新的串連,都會分配一個獨立線程來處理,因此監聽線程的任務比較輕鬆,mysql通過poll或select方式來實現IO的多工。引入線程池後,除了server層的監聽線程,每個group都有一個監聽線程負責監聽group內的所有串連socket的串連請求,背景工作執行緒不負責監聽,只處理請求。對於overscribe為1000的線程池設定,每個監聽線程需要監聽1000個socket的請求,監聽線程採用epoll方式來實現監聽。
Select,poll,epoll都是IO多工機制,IO多工通過一種機制,可以監聽多個fd(描述符),比如socket,一旦某個fd就緒(讀就緒或寫就緒),能夠通知程式進行相應的讀寫操作。epoll相對於select和poll有了很大的改進,首先epoll通過epoll_ctl函數註冊,註冊時,將所有fd拷貝進核心,只拷貝一次不需要重複拷貝,而每次調用poll或select時,都需要將fd集合從使用者空間拷貝到核心空間(epoll通過epoll_wait進行等待);其次,epoll為每個描述符指定了一個回呼函數,當裝置就緒時,喚醒等待者,通過回呼函數將描述符加入到就緒鏈表,無需像select,poll方式採用輪詢方式;最後select預設只支援1024個fd,epoll則沒有限制,具體數字可以參考cat /proc/sys/fs/file-max的設定。epoll貫穿線上程池使用的過程中,下面我就epoll的建立,使用和銷毀生命週期來描述epoll線上程中是如何使用的。
線程池初始化,epoll通過epoll_create函數建立epoll檔案描述符,實現函數是thread_group_init;
連接埠監聽線程監聽到請求後,建立socket,並建立THD和connection對象,放在對應的group隊列中;
背景工作執行緒擷取該connection對象時,若還未登入,則進行登入驗證
若socket還未註冊到epoll,則調用epoll_ctl進行註冊,註冊方式是EPOLL_CTL_ADD,並將connection對象放入epoll_event結構體中
若是老串連的請求,仍然需要調用epoll_ctl註冊,註冊方式是EPOLL_CTL_MOD
group內的監聽線程調用epoll_wait來監聽註冊的fd,epoll是一種同步IO方式,所以會進行等待
請求到來時,擷取epoll_event結構體中的connection,放入到group中的隊列
線程池銷毀時,調用thread_group_close將epoll關閉。
備忘:
1.註冊在epoll的fd,若請求就緒,則將對應的event放入到events數組,並將該fd的事務類型清空,因此對於老的串連請求,依然需要調用epoll_ctl(pollfd, EPOLL_CTL_MOD, fd, &ev)來註冊。
線程池函數調用關係
(1)建立epoll
tp_init->thread_group_init->tp_set_threadpool_size->io_poll_create->epoll_create
(2)關閉epoll
tp_end->thread_group_close->thread_group_destroy->close(pollfd)
(3)關聯socket描述符
handle_event->start_io->io_poll_associate_fd->io_poll_start_read->epoll_ctl
(4)處理串連請求
handle_event->threadpool_process_request->do_command->dispatch_command->mysql_parse->mysql_execute_command
(5)背景工作執行緒空閑時
worker_main->get_event->pthread_cond_timedwait
等待thread_pool_idle_timeout後,退出。
(6)監聽epoll
worker_main->get_event->listener->io_poll_wait->epoll_wait
(7)連接埠監聽線程
main->mysqld_main->handle_connections_sockets->poll
one-connection-per-thread函數調用關係
(1) 背景工作執行緒等待請求
handle_one_connection->do_handle_one_connection->do_command->my_net_read->net_read_packet->net_read_packet_header->net_read_raw_loop->vio_read->vio_socket_io_wait->vio_io_wait->poll
備忘:與線程池的背景工作執行緒有監聽線程協助其監聽請求不同,one-connection-per-thread方式的背景工作執行緒在空閑時,會調用poll阻塞等待網路包過來;
而線程池的背景工作執行緒只需要專心處理請求即可,所以使用也更充分。
(2)連接埠監聽線程
與線程池的(7)相同