1. 程式人生 > 其它 >原創|執行緒池詳解

原創|執行緒池詳解

原創|執行緒池詳解 https://mp.weixin.qq.com/s/BXEYVfYCsZ5fD2IG6fNVrA

原創|執行緒池詳解

騰訊資料庫技術騰訊資料庫技術2021-10-20 收錄於話題

「第一部分 背景」

社群版的MySQL的連線處理方法預設是為每個連線建立一個工作執行緒的one-thread-per-connection(Per_thread)模式。這種模式下,由於系統的資源是有限的,隨著連線數的增加,資源的競爭也增加,連線的響應時間也隨之增加,如response time圖所示。

對於資料庫整體吞吐而言,則是在資源未耗盡時隨著連線數增加,一旦連線數超過了某個耗盡系統資源的臨界點,資料庫整體吞吐就會隨著各連線的資源爭搶而下降,如下圖所示。

如何避免在連線數暴增時,因資源競爭而導致系統吞吐下降的問題呢?MariaDB&&Percona中給出了簡潔的答案:執行緒池。執行緒池的原理在部落格中(連結參考文獻1)有生動的介紹,其大致可類比為早高峰期間大量汽車想通過一座大橋,如果採用one-thread-per-connection的方式則放任汽車自由行駛,由於橋面寬度有限,最終將導致所有汽車寸步難行。執行緒池的解決方案是限制同時行駛的汽車數,讓橋面時刻保持最大吞吐,儘快讓所有汽車抵達對岸。迴歸到資料庫本身,執行緒池的思路即為限制同時執行的執行緒數,減少執行緒池間上下文切換和熱鎖爭用,從而對OLTP工作負載(CPU消耗較少的查詢)產生積極影響。當連線數上升時,線上程池的幫助下資料庫整體吞吐維持在一個較高水準,如圖所示。

「第二部分Percona執行緒池實現

執行緒池的基本原理為:預先建立一定數量的工作執行緒(worker執行緒)。線上程池監聽執行緒(listener執行緒)從現有連線中監聽到新請求時,從工作執行緒中分配一個執行緒來提供服務。工作執行緒在服務結束之後不銷燬執行緒,而是保留線上程池中繼續等待下一個請求來臨。下面我們將從執行緒池架構、新連線的建立與分配、listener執行緒、worker執行緒、timer執行緒等幾個方面來介紹percona執行緒池的實現。

2.1 執行緒池的架構

執行緒池由多個執行緒組(thread group)和timer執行緒組成,如下圖所示。執行緒組的數量是執行緒池併發的上限,通常而言執行緒組的數量需要配置成資料庫例項的CPU數量,從而充分利用CPU。執行緒池中還有一個服務於所有執行緒組的timer執行緒,負責週期性檢查執行緒組是否處於阻塞狀態。當檢測到阻塞的執行緒組時,timer執行緒會通過喚醒或建立新的工作執行緒來讓執行緒組恢復工作。

執行緒組內部由多個worker執行緒、0或1個listener執行緒、高低優先順序事件佇列(由網路事件event構成)、mutex、epollfd、統計資訊等組成。如下圖所示:

2.2 新連線的建立與分配

新連線接入時,執行緒池按照新連線的執行緒id取模執行緒組個數來確定新連線歸屬的執行緒組(thd→thread_id() % group_count)。這樣的分配邏輯非常簡潔,但由於沒有充分考慮連線的負載情況,繁忙的連線可能會恰巧被分配到相同的執行緒組,從而導致負載不均衡的現象,這是percona執行緒池值得被優化的點。

選定新連線歸屬的執行緒組後,新連線申請被作為事件放入低優先順序佇列中,等待執行緒組中worker執行緒將高優先順序事件佇列處理完後,就會處理低優先順序佇列中的請求。

2.3 listener執行緒

listener執行緒是負責監聽連線請求的執行緒,每個執行緒組都有一個listener執行緒。percona執行緒池的listener採用epoll實現。當epoll監聽到請求事件時,listener會根據請求事件的型別來決定將其放入哪個優先順序事件佇列。將事件放入高優先順序佇列的條件如下,只需要滿足其一即可:

  • 當前執行緒池的工作模式為高優先順序模式,在此模式下只啟用高優先順序佇列。(mode == TP_HIGH_PRIO_MODE_STATEMENTS)

  • 當前執行緒池的工作模式為高優先順序事務模式,在此模式下每個連線的event最多被放入高優先順序佇列threadpool_high_prio_tickets次。超過threadpool_high_prio_tickets次後,該連線的請求事件只能被放入低優先順序。(mode == TP_HIGH_PRIO_MODE_TRANSACTIONS)

  • 連線持有表鎖

  • 連線持有mdl鎖

  • 連線持有全域性讀鎖

  • 連線持有backup鎖

被放入高優先順序事件佇列的事件可以優先被worker執行緒處理。只有當高優先順序佇列為空,並且當前執行緒組不繁忙的時候才處理低優先順序佇列中的事件。執行緒組繁忙(too_many_busy_threads)的判斷條件是當前組內活躍工作執行緒數+組內處於等待狀態的執行緒數大於執行緒組工作執行緒額定值(thread_pool_oversubscribe+1)。這樣的設計可能帶來的問題是在高優先順序佇列不為空或者執行緒組繁忙時低優先順序佇列中的事件遲遲得不到響應,這同樣也是

percona執行緒池值得被優化的一個點。listener執行緒將事件放入高低優先順序佇列後,如果執行緒組的活躍worker數量為0,則喚醒或建立新的worker執行緒來處理事件。

percona的執行緒池中listener執行緒和worker執行緒是可以互相切換的,詳細的切換邏輯會在worker執行緒模組介紹。epoll監聽到請求事件時,如果高低優先順序事件佇列都為空,意味著此時執行緒組非常空閒,大概率不存在活躍的worker執行緒。listener在此情況下會將除第一個事件外的所有事件按前述規則放入高低優先順序事件佇列,然後退出監聽任務,親自處理第一個事件。這樣設計的好處在於當執行緒組非常空閒時,可以避免listener執行緒將事件放入佇列,喚醒或建立worker執行緒來處理事件的開銷,提高工作效率。

2.4 worker執行緒

worker執行緒是執行緒池中真正幹活的執行緒,正常情況下,每個執行緒組都會有一個活躍的worker執行緒。worker在理想狀態下,可以高效運轉並且快速處理完高低優先順序佇列中的事件。但是在實際場景中,worker經常會遭遇IO、鎖等等待情況而難以高效完成任務,此時任憑worker執行緒等待將使得在佇列中的事件遲遲得不到處理、甚至可能出現長時間沒有listener執行緒監聽新請求的情況。為此,每當worker遭遇IO、鎖等等待情況,如果此時執行緒組中沒有listener執行緒或者高低優先順序事件佇列非空,並且沒有過多活躍worker,則會嘗試喚醒或者建立一個worker。為了避免短時間內建立大量worker,帶來系統吞吐波動,執行緒池建立worker執行緒時有一個控制單位時間建立worker執行緒上限的邏輯,執行緒組內連線數越多則建立下一個執行緒需要等待的時間越長。

當執行緒組活躍worker執行緒數量大於等於too_many_active_threads+1時,認為執行緒組的活躍worker數量過多。此時需要對worker數量進行適當收斂,首先判斷當前執行緒組是否有listener執行緒,如果沒有則將當前worker執行緒轉化為listener執行緒。如果當前有listener執行緒,則在進入休眠前嘗試通過epoll_wait獲取一個尚未進入佇列的事件,成功獲取到後立刻處理該事件,否則進入休眠等待被喚醒,等待threadpool_idle_timeout時間後仍未被喚醒則銷燬該worker執行緒。

worker執行緒與listener執行緒的切換如下圖所示:

2.5 timer執行緒

timer執行緒每隔threadpool_stall_limit時間進行一次所有執行緒組的掃描(check_stall)。當執行緒組高低優先順序佇列中存在事件,並且自上次檢查至今沒有新的事件被worker消費則認為執行緒組處於停滯狀態。停滯的主要原因可能是長時間執行的非阻塞請求, 也可能發生於執行緒正在等待但 wait_begin/wait_end (嘗試喚醒或建立新的worker執行緒)被上層函式忘記呼叫的場景。timer執行緒會通過喚醒或建立新的worker執行緒來讓停滯的執行緒組恢復工作。timer執行緒為了儘量減少對正常工作的執行緒組的影響,在check_stall時採用的是try_lock的方式,如果加不上鎖則認為執行緒組運轉良好,不再去打擾。

timer執行緒除上述工作外,還負責終止空閒時間超過 wait_timeout 秒的客戶端。

「第三部分 TXSQL動態執行緒池優化

執行緒池採用一定數量的工作執行緒來處理使用者連線請求,通常比較適應於OLTP工作負載的場景。但執行緒池並不是萬能的,執行緒池的不足在於當用戶請求偏向於慢查詢時,工作執行緒阻塞在高時延操作上,難以快速響應新的使用者請求,導致系統吞吐量反而相較於Per_thread模式更低。

Per_thread模式與Thread_pool模式各有優缺點,系統需要根據使用者的業務型別靈活地進行切換。遺憾的是,當前兩種模式的切換必須重啟伺服器才能完成。通常而言,兩種模式相互轉換的需求都是出現在業務高峰時段,此時強制重啟伺服器將對使用者業務造成嚴重影響。為了提高Per_thread模式與Thread_pool模式切換的靈活程度,TXSQL提出了執行緒池動態切換的優化,即在不重啟資料庫服務的情況下,動態開啟或關閉執行緒池。

3.1 動態執行緒池的實現介紹

mysql的thread_handling引數代表了連線管理方法。在過去thread_handling是隻讀引數,不允許線上修改。thread_handling引數對應的底層實現物件是Connection_handler_manager,後者是是mysql提供連線管理服務的單例類,可對外提供Per_thread、No_threads、Thread_pool、Plugin_connection_handler等多種連線管理服務。由於thread_handling在過去是隻讀引數,在mysql啟動時Connection_handler_manager只需要按照thread_handling初始化一種連線管理方法即可。為了支援動態執行緒池,允許使用者連線從Per_thread和Thread_pool模式中來回切換,我們需要允許多種連線管理方法同時存在。因此,在mysql初始化階段,我們初始化了所有連線管理方法。

在支援thread_handling在Per_thread和Thread_pool模式中來回切換後,我們需要考慮的問題主要有以下幾個:

1) 活躍使用者連線的thread_handling切換

Per_thread模式下,每個使用者連線對應一個handle_connection執行緒,handle_connection執行緒既負責使用者網路請求的監聽,又負責處理請求的處理。Thread_pool模式下,每個thread_group都用epoll來管理其中所有使用者連線的網路事件,監聽到的事件放入事件佇列中,交予worker處理。不論是哪種模式,在處理請求的過程中(do_command)切換都不是一個好選擇,而在完成一次command之後,尚未接到下一次請求之前是一個較合適的切換點。

為實現使用者連線從Per_thread到Thread_pool的切換,需要在請求處理完(do_command)之後判斷thread_handling是否發生了變化。如需切換則立刻按照2.2中介紹的邏輯,通過thread_id%group_size選定目標thread_group,將當前使用者連線遷移至Thread_pool的目標thread_group中,後續該使用者連線的所有網路事件統一交予thread_group的epoll監聽。在完成連線遷移之後,handle_connection執行緒即可完成退出或者快取至下一次Per_thread模式處理新連線時複用(此為原生mysql支援的邏輯,目的是避免Per_thread模式下頻繁地建立和銷燬handle_connection執行緒)。

為實現使用者連線從Thread_pool到Per_thread的切換,需要在請求處理完(threadpool_process_request)後,將使用者執行緒網路控制代碼重新掛載到epoll(start_io)之前判斷thread_handling是否發生了變化。如需切換則先將網路控制代碼從epoll中移除以及將連線的資訊從對應thread_group中清除。由於Per_thread模式下每個連線對應一個handle_connection執行緒,還需為當前使用者連線建立一個handle_connection執行緒,後續當前使用者連線的網路監聽和請求處理都交予該handle_connection執行緒處理。

2) 新連線的處理

由於thread_handling可能隨時動態變化,為了使得新連線能被新thread_handling處理,需要在新連線處理介面Connection_handler_manager::process_new_connection中,讀取最新的thread_handling,利用其相應的連線管理方法新增新連線。對於Per_thread模式,需要為新連線建立handle_connection執行緒;對於Thread_pool模式,則需要為新連線選定thread_group和將其網路控制代碼繫結到thread_group的epoll中。

3) thread_handling切換的快速生效

從上述1)的討論中可以看到,處於連線狀態的使用者執行緒需要等到一個請求處理結束才會等到合適的切換點。如果該使用者連線遲遲不傳送網路請求,則連線會阻塞在do_command下的get_command的網路等待中,無法及時切換到Thread_pool。如何快速完成此類執行緒的切換呢?一種比較激進的方法就是迫使此類連線重連,在重連後作為新連線自然地切換到Thread_pool中,其下一個網路請求也將被Thread_pool應答。

「第四部分 TXSQL執行緒池負載均衡優化

如前文2.2所述,新連線按照執行緒id取模執行緒組個數來確定新連線歸屬的執行緒組(thd→thread_id() % group_count)。這樣的分配方式未能將各執行緒組的實際負載考慮在內,因此可能將繁忙的連線分配到相同的執行緒組,使得執行緒池出現負載不均衡的現象。為了避免負載不均衡的發生,TXSQL提出了執行緒池負載均衡優化。

4.1 負載的度量

在提出負載均衡的演算法之前,我們首先需要找到一種度量執行緒組負載狀態的方法,通常我們稱之為"資訊策略“。下面我們分別討論幾種可能的資訊策略。

1)queue_length

queue_length代表執行緒組中低優先順序佇列和高優先順序佇列的長度。此資訊策略的最大優勢在於簡單,直接用在工作佇列中尚未處理的event的數量描述當前執行緒組的工作負載情況。此資訊策略的不足,無法將每個網路事件event的處理效率納入考量。由於每個event的處理效率並不相同,簡單地以工作佇列長度作為度量標準會帶來一些誤判。

2) average_wait_usecs_in_queue

average_wait_usecs_in_queue表示最近n個event在佇列中的平均等待時間。此資訊策略的優勢在於能夠直觀地反映執行緒組處理event的響應速度。某執行緒組average_wait_usecs_in_queue明顯高於其他執行緒組說明其工作佇列中的event無法及時被處理,需要其他執行緒組對其提供幫助。

3) group_efficiency

group_efficiency表示一定的時間週期內,執行緒組處理完的event總數佔(工作佇列存量event數+新增event數)的比例。此資訊策略的優勢在於能夠直觀反映出執行緒組一定時間週期內的工作效率,不足在於對於運轉良好的執行緒組也可能存在誤判:當時間週期選擇不合適時,運轉良好的執行緒組可能存在時而group_efficiency小於1,時而大於1的情況。

上述三種資訊策略只是舉例說明,還有更多資訊策略可以被採用,就不再一一羅列。

4.2 負載均衡的實現介紹

在明確了度量執行緒組負載的方法之後,我們接下來討論如何均衡負載。我們需要考慮的問題主要如下:

1) 負載均衡演算法的觸發條件

負載均衡操作會將使用者連線從一個執行緒組遷移至另一個執行緒組,在非必要情況下觸發使用者連線的遷移將因反而導致使用者連線的效能抖動。為儘可能避免負載均衡演算法錯誤觸發,我們需要為觸發負載均衡演算法設定一個負載閾值M,以及負載比例N。只有執行緒組的負載閾值大於M,並且其與參與均衡負載的執行緒組的負載比例大於N時,才需要啟動負載均衡演算法平衡負載。

2) 負載均衡的引數物件

當執行緒組觸發了負載均衡演算法後,該由哪些執行緒組參與平衡高負載執行緒組的負載呢?

很容易想到的一個方案是我們維護全域性的執行緒組負載動態序列,讓負載最輕的執行緒組負責分擔負載。但是遺憾的是為了維護全域性執行緒組負載動態序列,執行緒組每處理完一次任務都可能需要更新自身的狀態,並在全域性鎖的保護下更新其在全域性負載序列中的位置,如此一來對效能的影響勢必較大,因此全域性執行緒組負載動態序列的方案並不理想。

為了避免均衡負載對執行緒池整體效能的影響,需改全域性負載比較為區域性負載比較。一種可能的方法為噹噹前執行緒組的負載高於閾值M時,只比較其與左右相鄰的X個(通常1-2個)執行緒組的負載差異,噹噹前執行緒組的負載與相鄰執行緒組的比例也高於N倍時,從當前執行緒組向低負載執行緒組遷移使用者連線。需要注意的是噹噹前執行緒組的負載與相鄰執行緒組的比例不足N倍時,說明要麼當前執行緒組還不夠繁忙、要麼其相鄰執行緒組也較為忙碌,此時為了避免執行緒池整體表現惡化,不適合強行均衡負載。

3) 均衡負載的方法

討論完負載均衡的觸發條件及參與物件之後,接下來我們需要討論高負載執行緒組向低負載執行緒組遷移負載的方法。總體而言,包括兩種方法:新連線的優化分配、舊連線的合理轉移。

在掌握了執行緒組的量化負載之後,較容易實現的均衡負載方法是在新連線分配執行緒組時特意避開高負載執行緒組,這樣一來已經處於高負載狀態的執行緒組便不會因新連線的加入進一步惡化。但僅僅如此還不夠,如果高負載執行緒組的響應已經很遲鈍,我們還需要主動將其中的舊連線遷移至合適的低負載執行緒組,具體遷移時機在3.1中已有述及,為在請求處理完(threadpool_process_request)後,將使用者執行緒網路控制代碼重新掛載到epoll(start_io)之前,此處便不再展開討論。

「第五部分 TXSQL執行緒池斷連優化

如前文2.3所述,執行緒池採用epoll來處理網路事件。當epoll監聽到網路事件時,listener會將網路事件放入事件佇列或自己處理,此時相應使用者連線不會被epoll監聽。percona執行緒池需要等到請求處理結束之後才會使用epoll重新監聽使用者連線的新網路事件。percona執行緒池這樣的設計通常不會帶來問題,因為使用者連線在請求未被處理時,也不會有傳送新請求的需求。但特殊情況下,如果使用者連線在重新被epoll監聽前自行退出了,此時使用者連線發出的斷連訊號無法被epoll捕捉,因此在mysql伺服器端無法及時退出該使用者連線。這樣帶來的影響主要有兩點:

  1. 使用者連線客戶端雖已退出,但mysql伺服器端卻仍在執行該連線,繼續消耗CPU、記憶體資源,甚至可能繼續持有鎖,只有等到連線超時才能退出;

  2. 由於使用者連線在mysql伺服器端未及時退出,連線數也並未清理,如果使用者業務連線數較多,可能導致使用者新連線數觸達最大連線數上限,使用者無法連線資料庫,嚴重影響業務。

為解決上述問題,TXSQL提出了執行緒池斷連優化。

5.1 斷連優化的實現介紹

斷連優化的重點在於及時監聽使用者連線的斷連事件並及時處理。為此需要作出的優化如下:

  1. 在epoll接到使用者連線的正常網路事件後,立刻監聽該使用者連線的斷連事件;

  2. 所有使用者連線退出從同步改為非同步,所有退出的連線先放入quit_connection_queue,後統一處理;

  3. 一旦epoll接到斷連事件後立刻將使用者連線thd→killed設定為THD::KILL_CONNECTION狀態,並將連線放入quit_connection_queue中非同步退出;

  4. listener每隔固定時間(例如100ms)處理一次quit_connection_queue,讓其中的使用者連線退出。

「第六部分 執行緒池相關引數、狀態介紹

以下是對執行緒池相關引數的介紹:

引數名引數說明預設值有效值範圍
thread_pool_idle_timeout worker執行緒在沒有需要處理的網路事件時,最多等待此時間(單位秒)後銷燬 60 (1,UINT_MAX)
thread_pool_oversubscribe 在一個工作組中最多允許多少個worker 3 (1,1000)
thread_pool_size 執行緒組個數 物理機CPU個數 (1,1000)
thread_pool_stall_limit

每間隔此時間(單位毫秒)timer執行緒負責遍歷檢查一次所有執行緒組。當執行緒組沒有listener、高低優先順序佇列非空並且沒有新增的IO網路事件時認為執行緒組處於stall狀態,timer執行緒負責喚醒或建立新的worker執行緒來緩解該執行緒組的壓力。

500 (10,UINT_MAX)
thread_pool_max_threads 執行緒池中所有worker執行緒的總數 100000 (1,100000)
thread_pool_high_prio_mode

高優先順序佇列工作模式,包括三種:

transactions:只有一個已經開啟了事務的SQL,並且thread_pool_high_prio_tickets不為0,才會進入到高優先順序佇列中,每個連線在thread_pool_high_prio_tickets次被放到優先佇列中後,會移到普通佇列中;

statement:所有連線都被放入高優先順序佇列中;

none:與statement相反,所有連線都被放入低優先順序佇列中。

transactions transactions/statement/none

thread_pool_high_prio_tickets

transactions工作模式下,給每個連線的授予的tickets大小

UINT_MAX

(0,UINT_MAX)

threadpool_workaround_epoll_bug

是否繞過linux2.x中的epoll bug,該bug在linux 3中修復

no

no/yes

下面對TXSQL新增的show threadpool status命令展示的相關狀態進行說明:

狀態名狀態說明
groupid 執行緒組id
connection_count 執行緒組使用者連線數
thread_count 執行緒組內工作執行緒數
havelistener 執行緒組當前是否存在listener
active_thread_count 執行緒組內活躍worker數量
waiting_thread_count 執行緒組內等待中的worker數量(呼叫wait_begin的worker)
waiting_threads_size 執行緒組中無網路事件需要處理,進入休眠期等待被喚醒的worker數量(等待thread_pool_idle_timeout秒後自動銷燬)
queue_size 執行緒組普通優先順序佇列長度
high_prio_queue_size 執行緒組高優先順序佇列長度

get_high_prio_queue_num

執行緒組內事件從高優先順序佇列被取走的總次數

get_normal_queue_num

執行緒組內事件從普通優先順序佇列被取走的總次數

create_thread_num

執行緒組內建立的worker執行緒總數

wake_thread_num

執行緒組內從waiting_threads佇列中喚醒的worker總數

oversubscribed_num

執行緒組內worker發現當前執行緒組處於oversubscribed狀態,並且準備進入休眠的次數

mysql_cond_timedwait_num

執行緒組內worker進入waiting_threads佇列的總次數

check_stall_nolistener

執行緒組被timer執行緒check_stall檢查中發現沒有listener的總次數

check_stall_stall

執行緒組被timer執行緒check_stall檢查中被判定為stall狀態的總次數

max_req_latency_us

執行緒組中使用者連線在佇列等待的最長時間(單位毫秒)

conns_timeout_killed

執行緒組中使用者連線因客戶端無新訊息時間超過閾值(net_wait_timeout)被killed的總次數

connections_moved_in

從其他執行緒組中遷入該執行緒組的連線總數

connections_moved_out

從該執行緒組遷出到其他執行緒組的連線總數

connections_moved_from_per_thread

從one-thread-per-connection模式中遷入該執行緒組的連線總數

connections_moved_to_per_thread

從該執行緒組中遷出到one-thread-per-connection模式的連線總數

events_consumed

執行緒組處理過的events總數

average_wait_usecs_in_queue

執行緒組內所有events在佇列中的平均等待時間

「第七部分 總結

本文從背景、原理、架構、實現、引數狀態等方面介紹了percona-執行緒池。此外,還簡單介紹了TXSQL關於執行緒池的動態啟停、負載均衡以及快速斷連等優化。

「第八部分 參考文獻

【參考文獻1】https://www.percona.com/blog/2013/03/16/simcity-outages-traffic-control-and-thread-pool-for-mysql/

【參考文獻2】https://dbaplus.cn/news-11-1989-1.html

【參考文獻3】https://www.percona.com/doc/percona-server/5.7/performance/threadpool.html

【參考文獻4https://mariadb.com/kb/en/thread-pool-in-mariadb/

原創|執行緒池詳解

騰訊資料庫技術騰訊資料庫技術2021-10-20 收錄於話題

「第一部分 背景」

社群版的MySQL的連線處理方法預設是為每個連線建立一個工作執行緒的one-thread-per-connection(Per_thread)模式。這種模式下,由於系統的資源是有限的,隨著連線數的增加,資源的競爭也增加,連線的響應時間也隨之增加,如response time圖所示。

對於資料庫整體吞吐而言,則是在資源未耗盡時隨著連線數增加,一旦連線數超過了某個耗盡系統資源的臨界點,資料庫整體吞吐就會隨著各連線的資源爭搶而下降,如下圖所示。

如何避免在連線數暴增時,因資源競爭而導致系統吞吐下降的問題呢?MariaDB&&Percona中給出了簡潔的答案:執行緒池。執行緒池的原理在部落格中(連結參考文獻1)有生動的介紹,其大致可類比為早高峰期間大量汽車想通過一座大橋,如果採用one-thread-per-connection的方式則放任汽車自由行駛,由於橋面寬度有限,最終將導致所有汽車寸步難行。執行緒池的解決方案是限制同時行駛的汽車數,讓橋面時刻保持最大吞吐,儘快讓所有汽車抵達對岸。迴歸到資料庫本身,執行緒池的思路即為限制同時執行的執行緒數,減少執行緒池間上下文切換和熱鎖爭用,從而對OLTP工作負載(CPU消耗較少的查詢)產生積極影響。當連線數上升時,線上程池的幫助下資料庫整體吞吐維持在一個較高水準,如圖所示。

「第二部分Percona執行緒池實現

執行緒池的基本原理為:預先建立一定數量的工作執行緒(worker執行緒)。線上程池監聽執行緒(listener執行緒)從現有連線中監聽到新請求時,從工作執行緒中分配一個執行緒來提供服務。工作執行緒在服務結束之後不銷燬執行緒,而是保留線上程池中繼續等待下一個請求來臨。下面我們將從執行緒池架構、新連線的建立與分配、listener執行緒、worker執行緒、timer執行緒等幾個方面來介紹percona執行緒池的實現。

2.1 執行緒池的架構

執行緒池由多個執行緒組(thread group)和timer執行緒組成,如下圖所示。執行緒組的數量是執行緒池併發的上限,通常而言執行緒組的數量需要配置成資料庫例項的CPU數量,從而充分利用CPU。執行緒池中還有一個服務於所有執行緒組的timer執行緒,負責週期性檢查執行緒組是否處於阻塞狀態。當檢測到阻塞的執行緒組時,timer執行緒會通過喚醒或建立新的工作執行緒來讓執行緒組恢復工作。

執行緒組內部由多個worker執行緒、0或1個listener執行緒、高低優先順序事件佇列(由網路事件event構成)、mutex、epollfd、統計資訊等組成。如下圖所示:

2.2 新連線的建立與分配

新連線接入時,執行緒池按照新連線的執行緒id取模執行緒組個數來確定新連線歸屬的執行緒組(thd→thread_id() % group_count)。這樣的分配邏輯非常簡潔,但由於沒有充分考慮連線的負載情況,繁忙的連線可能會恰巧被分配到相同的執行緒組,從而導致負載不均衡的現象,這是percona執行緒池值得被優化的點。

選定新連線歸屬的執行緒組後,新連線申請被作為事件放入低優先順序佇列中,等待執行緒組中worker執行緒將高優先順序事件佇列處理完後,就會處理低優先順序佇列中的請求。

2.3 listener執行緒

listener執行緒是負責監聽連線請求的執行緒,每個執行緒組都有一個listener執行緒。percona執行緒池的listener採用epoll實現。當epoll監聽到請求事件時,listener會根據請求事件的型別來決定將其放入哪個優先順序事件佇列。將事件放入高優先順序佇列的條件如下,只需要滿足其一即可:

  • 當前執行緒池的工作模式為高優先順序模式,在此模式下只啟用高優先順序佇列。(mode == TP_HIGH_PRIO_MODE_STATEMENTS)

  • 當前執行緒池的工作模式為高優先順序事務模式,在此模式下每個連線的event最多被放入高優先順序佇列threadpool_high_prio_tickets次。超過threadpool_high_prio_tickets次後,該連線的請求事件只能被放入低優先順序。(mode == TP_HIGH_PRIO_MODE_TRANSACTIONS)

  • 連線持有表鎖

  • 連線持有mdl鎖

  • 連線持有全域性讀鎖

  • 連線持有backup鎖

被放入高優先順序事件佇列的事件可以優先被worker執行緒處理。只有當高優先順序佇列為空,並且當前執行緒組不繁忙的時候才處理低優先順序佇列中的事件。執行緒組繁忙(too_many_busy_threads)的判斷條件是當前組內活躍工作執行緒數+組內處於等待狀態的執行緒數大於執行緒組工作執行緒額定值(thread_pool_oversubscribe+1)。這樣的設計可能帶來的問題是在高優先順序佇列不為空或者執行緒組繁忙時低優先順序佇列中的事件遲遲得不到響應,這同樣也是

percona執行緒池值得被優化的一個點。listener執行緒將事件放入高低優先順序佇列後,如果執行緒組的活躍worker數量為0,則喚醒或建立新的worker執行緒來處理事件。

percona的執行緒池中listener執行緒和worker執行緒是可以互相切換的,詳細的切換邏輯會在worker執行緒模組介紹。epoll監聽到請求事件時,如果高低優先順序事件佇列都為空,意味著此時執行緒組非常空閒,大概率不存在活躍的worker執行緒。listener在此情況下會將除第一個事件外的所有事件按前述規則放入高低優先順序事件佇列,然後退出監聽任務,親自處理第一個事件。這樣設計的好處在於當執行緒組非常空閒時,可以避免listener執行緒將事件放入佇列,喚醒或建立worker執行緒來處理事件的開銷,提高工作效率。

2.4 worker執行緒

worker執行緒是執行緒池中真正幹活的執行緒,正常情況下,每個執行緒組都會有一個活躍的worker執行緒。worker在理想狀態下,可以高效運轉並且快速處理完高低優先順序佇列中的事件。但是在實際場景中,worker經常會遭遇IO、鎖等等待情況而難以高效完成任務,此時任憑worker執行緒等待將使得在佇列中的事件遲遲得不到處理、甚至可能出現長時間沒有listener執行緒監聽新請求的情況。為此,每當worker遭遇IO、鎖等等待情況,如果此時執行緒組中沒有listener執行緒或者高低優先順序事件佇列非空,並且沒有過多活躍worker,則會嘗試喚醒或者建立一個worker。為了避免短時間內建立大量worker,帶來系統吞吐波動,執行緒池建立worker執行緒時有一個控制單位時間建立worker執行緒上限的邏輯,執行緒組內連線數越多則建立下一個執行緒需要等待的時間越長。

當執行緒組活躍worker執行緒數量大於等於too_many_active_threads+1時,認為執行緒組的活躍worker數量過多。此時需要對worker數量進行適當收斂,首先判斷當前執行緒組是否有listener執行緒,如果沒有則將當前worker執行緒轉化為listener執行緒。如果當前有listener執行緒,則在進入休眠前嘗試通過epoll_wait獲取一個尚未進入佇列的事件,成功獲取到後立刻處理該事件,否則進入休眠等待被喚醒,等待threadpool_idle_timeout時間後仍未被喚醒則銷燬該worker執行緒。

worker執行緒與listener執行緒的切換如下圖所示:

2.5 timer執行緒

timer執行緒每隔threadpool_stall_limit時間進行一次所有執行緒組的掃描(check_stall)。當執行緒組高低優先順序佇列中存在事件,並且自上次檢查至今沒有新的事件被worker消費則認為執行緒組處於停滯狀態。停滯的主要原因可能是長時間執行的非阻塞請求, 也可能發生於執行緒正在等待但 wait_begin/wait_end (嘗試喚醒或建立新的worker執行緒)被上層函式忘記呼叫的場景。timer執行緒會通過喚醒或建立新的worker執行緒來讓停滯的執行緒組恢復工作。timer執行緒為了儘量減少對正常工作的執行緒組的影響,在check_stall時採用的是try_lock的方式,如果加不上鎖則認為執行緒組運轉良好,不再去打擾。

timer執行緒除上述工作外,還負責終止空閒時間超過 wait_timeout 秒的客戶端。

「第三部分 TXSQL動態執行緒池優化

執行緒池採用一定數量的工作執行緒來處理使用者連線請求,通常比較適應於OLTP工作負載的場景。但執行緒池並不是萬能的,執行緒池的不足在於當用戶請求偏向於慢查詢時,工作執行緒阻塞在高時延操作上,難以快速響應新的使用者請求,導致系統吞吐量反而相較於Per_thread模式更低。

Per_thread模式與Thread_pool模式各有優缺點,系統需要根據使用者的業務型別靈活地進行切換。遺憾的是,當前兩種模式的切換必須重啟伺服器才能完成。通常而言,兩種模式相互轉換的需求都是出現在業務高峰時段,此時強制重啟伺服器將對使用者業務造成嚴重影響。為了提高Per_thread模式與Thread_pool模式切換的靈活程度,TXSQL提出了執行緒池動態切換的優化,即在不重啟資料庫服務的情況下,動態開啟或關閉執行緒池。

3.1 動態執行緒池的實現介紹

mysql的thread_handling引數代表了連線管理方法。在過去thread_handling是隻讀引數,不允許線上修改。thread_handling引數對應的底層實現物件是Connection_handler_manager,後者是是mysql提供連線管理服務的單例類,可對外提供Per_thread、No_threads、Thread_pool、Plugin_connection_handler等多種連線管理服務。由於thread_handling在過去是隻讀引數,在mysql啟動時Connection_handler_manager只需要按照thread_handling初始化一種連線管理方法即可。為了支援動態執行緒池,允許使用者連線從Per_thread和Thread_pool模式中來回切換,我們需要允許多種連線管理方法同時存在。因此,在mysql初始化階段,我們初始化了所有連線管理方法。

在支援thread_handling在Per_thread和Thread_pool模式中來回切換後,我們需要考慮的問題主要有以下幾個:

1) 活躍使用者連線的thread_handling切換

Per_thread模式下,每個使用者連線對應一個handle_connection執行緒,handle_connection執行緒既負責使用者網路請求的監聽,又負責處理請求的處理。Thread_pool模式下,每個thread_group都用epoll來管理其中所有使用者連線的網路事件,監聽到的事件放入事件佇列中,交予worker處理。不論是哪種模式,在處理請求的過程中(do_command)切換都不是一個好選擇,而在完成一次command之後,尚未接到下一次請求之前是一個較合適的切換點。

為實現使用者連線從Per_thread到Thread_pool的切換,需要在請求處理完(do_command)之後判斷thread_handling是否發生了變化。如需切換則立刻按照2.2中介紹的邏輯,通過thread_id%group_size選定目標thread_group,將當前使用者連線遷移至Thread_pool的目標thread_group中,後續該使用者連線的所有網路事件統一交予thread_group的epoll監聽。在完成連線遷移之後,handle_connection執行緒即可完成退出或者快取至下一次Per_thread模式處理新連線時複用(此為原生mysql支援的邏輯,目的是避免Per_thread模式下頻繁地建立和銷燬handle_connection執行緒)。

為實現使用者連線從Thread_pool到Per_thread的切換,需要在請求處理完(threadpool_process_request)後,將使用者執行緒網路控制代碼重新掛載到epoll(start_io)之前判斷thread_handling是否發生了變化。如需切換則先將網路控制代碼從epoll中移除以及將連線的資訊從對應thread_group中清除。由於Per_thread模式下每個連線對應一個handle_connection執行緒,還需為當前使用者連線建立一個handle_connection執行緒,後續當前使用者連線的網路監聽和請求處理都交予該handle_connection執行緒處理。

2) 新連線的處理

由於thread_handling可能隨時動態變化,為了使得新連線能被新thread_handling處理,需要在新連線處理介面Connection_handler_manager::process_new_connection中,讀取最新的thread_handling,利用其相應的連線管理方法新增新連線。對於Per_thread模式,需要為新連線建立handle_connection執行緒;對於Thread_pool模式,則需要為新連線選定thread_group和將其網路控制代碼繫結到thread_group的epoll中。

3) thread_handling切換的快速生效

從上述1)的討論中可以看到,處於連線狀態的使用者執行緒需要等到一個請求處理結束才會等到合適的切換點。如果該使用者連線遲遲不傳送網路請求,則連線會阻塞在do_command下的get_command的網路等待中,無法及時切換到Thread_pool。如何快速完成此類執行緒的切換呢?一種比較激進的方法就是迫使此類連線重連,在重連後作為新連線自然地切換到Thread_pool中,其下一個網路請求也將被Thread_pool應答。

「第四部分 TXSQL執行緒池負載均衡優化

如前文2.2所述,新連線按照執行緒id取模執行緒組個數來確定新連線歸屬的執行緒組(thd→thread_id() % group_count)。這樣的分配方式未能將各執行緒組的實際負載考慮在內,因此可能將繁忙的連線分配到相同的執行緒組,使得執行緒池出現負載不均衡的現象。為了避免負載不均衡的發生,TXSQL提出了執行緒池負載均衡優化。

4.1 負載的度量

在提出負載均衡的演算法之前,我們首先需要找到一種度量執行緒組負載狀態的方法,通常我們稱之為"資訊策略“。下面我們分別討論幾種可能的資訊策略。

1)queue_length

queue_length代表執行緒組中低優先順序佇列和高優先順序佇列的長度。此資訊策略的最大優勢在於簡單,直接用在工作佇列中尚未處理的event的數量描述當前執行緒組的工作負載情況。此資訊策略的不足,無法將每個網路事件event的處理效率納入考量。由於每個event的處理效率並不相同,簡單地以工作佇列長度作為度量標準會帶來一些誤判。

2) average_wait_usecs_in_queue

average_wait_usecs_in_queue表示最近n個event在佇列中的平均等待時間。此資訊策略的優勢在於能夠直觀地反映執行緒組處理event的響應速度。某執行緒組average_wait_usecs_in_queue明顯高於其他執行緒組說明其工作佇列中的event無法及時被處理,需要其他執行緒組對其提供幫助。

3) group_efficiency

group_efficiency表示一定的時間週期內,執行緒組處理完的event總數佔(工作佇列存量event數+新增event數)的比例。此資訊策略的優勢在於能夠直觀反映出執行緒組一定時間週期內的工作效率,不足在於對於運轉良好的執行緒組也可能存在誤判:當時間週期選擇不合適時,運轉良好的執行緒組可能存在時而group_efficiency小於1,時而大於1的情況。

上述三種資訊策略只是舉例說明,還有更多資訊策略可以被採用,就不再一一羅列。

4.2 負載均衡的實現介紹

在明確了度量執行緒組負載的方法之後,我們接下來討論如何均衡負載。我們需要考慮的問題主要如下:

1) 負載均衡演算法的觸發條件

負載均衡操作會將使用者連線從一個執行緒組遷移至另一個執行緒組,在非必要情況下觸發使用者連線的遷移將因反而導致使用者連線的效能抖動。為儘可能避免負載均衡演算法錯誤觸發,我們需要為觸發負載均衡演算法設定一個負載閾值M,以及負載比例N。只有執行緒組的負載閾值大於M,並且其與參與均衡負載的執行緒組的負載比例大於N時,才需要啟動負載均衡演算法平衡負載。

2) 負載均衡的引數物件

當執行緒組觸發了負載均衡演算法後,該由哪些執行緒組參與平衡高負載執行緒組的負載呢?

很容易想到的一個方案是我們維護全域性的執行緒組負載動態序列,讓負載最輕的執行緒組負責分擔負載。但是遺憾的是為了維護全域性執行緒組負載動態序列,執行緒組每處理完一次任務都可能需要更新自身的狀態,並在全域性鎖的保護下更新其在全域性負載序列中的位置,如此一來對效能的影響勢必較大,因此全域性執行緒組負載動態序列的方案並不理想。

為了避免均衡負載對執行緒池整體效能的影響,需改全域性負載比較為區域性負載比較。一種可能的方法為噹噹前執行緒組的負載高於閾值M時,只比較其與左右相鄰的X個(通常1-2個)執行緒組的負載差異,噹噹前執行緒組的負載與相鄰執行緒組的比例也高於N倍時,從當前執行緒組向低負載執行緒組遷移使用者連線。需要注意的是噹噹前執行緒組的負載與相鄰執行緒組的比例不足N倍時,說明要麼當前執行緒組還不夠繁忙、要麼其相鄰執行緒組也較為忙碌,此時為了避免執行緒池整體表現惡化,不適合強行均衡負載。

3) 均衡負載的方法

討論完負載均衡的觸發條件及參與物件之後,接下來我們需要討論高負載執行緒組向低負載執行緒組遷移負載的方法。總體而言,包括兩種方法:新連線的優化分配、舊連線的合理轉移。

在掌握了執行緒組的量化負載之後,較容易實現的均衡負載方法是在新連線分配執行緒組時特意避開高負載執行緒組,這樣一來已經處於高負載狀態的執行緒組便不會因新連線的加入進一步惡化。但僅僅如此還不夠,如果高負載執行緒組的響應已經很遲鈍,我們還需要主動將其中的舊連線遷移至合適的低負載執行緒組,具體遷移時機在3.1中已有述及,為在請求處理完(threadpool_process_request)後,將使用者執行緒網路控制代碼重新掛載到epoll(start_io)之前,此處便不再展開討論。

「第五部分 TXSQL執行緒池斷連優化

如前文2.3所述,執行緒池採用epoll來處理網路事件。當epoll監聽到網路事件時,listener會將網路事件放入事件佇列或自己處理,此時相應使用者連線不會被epoll監聽。percona執行緒池需要等到請求處理結束之後才會使用epoll重新監聽使用者連線的新網路事件。percona執行緒池這樣的設計通常不會帶來問題,因為使用者連線在請求未被處理時,也不會有傳送新請求的需求。但特殊情況下,如果使用者連線在重新被epoll監聽前自行退出了,此時使用者連線發出的斷連訊號無法被epoll捕捉,因此在mysql伺服器端無法及時退出該使用者連線。這樣帶來的影響主要有兩點:

  1. 使用者連線客戶端雖已退出,但mysql伺服器端卻仍在執行該連線,繼續消耗CPU、記憶體資源,甚至可能繼續持有鎖,只有等到連線超時才能退出;

  2. 由於使用者連線在mysql伺服器端未及時退出,連線數也並未清理,如果使用者業務連線數較多,可能導致使用者新連線數觸達最大連線數上限,使用者無法連線資料庫,嚴重影響業務。

為解決上述問題,TXSQL提出了執行緒池斷連優化。

5.1 斷連優化的實現介紹

斷連優化的重點在於及時監聽使用者連線的斷連事件並及時處理。為此需要作出的優化如下:

  1. 在epoll接到使用者連線的正常網路事件後,立刻監聽該使用者連線的斷連事件;

  2. 所有使用者連線退出從同步改為非同步,所有退出的連線先放入quit_connection_queue,後統一處理;

  3. 一旦epoll接到斷連事件後立刻將使用者連線thd→killed設定為THD::KILL_CONNECTION狀態,並將連線放入quit_connection_queue中非同步退出;

  4. listener每隔固定時間(例如100ms)處理一次quit_connection_queue,讓其中的使用者連線退出。

「第六部分 執行緒池相關引數、狀態介紹

以下是對執行緒池相關引數的介紹:

引數名引數說明預設值有效值範圍
thread_pool_idle_timeout worker執行緒在沒有需要處理的網路事件時,最多等待此時間(單位秒)後銷燬 60 (1,UINT_MAX)
thread_pool_oversubscribe 在一個工作組中最多允許多少個worker 3 (1,1000)
thread_pool_size 執行緒組個數 物理機CPU個數 (1,1000)
thread_pool_stall_limit

每間隔此時間(單位毫秒)timer執行緒負責遍歷檢查一次所有執行緒組。當執行緒組沒有listener、高低優先順序佇列非空並且沒有新增的IO網路事件時認為執行緒組處於stall狀態,timer執行緒負責喚醒或建立新的worker執行緒來緩解該執行緒組的壓力。

500 (10,UINT_MAX)
thread_pool_max_threads 執行緒池中所有worker執行緒的總數 100000 (1,100000)
thread_pool_high_prio_mode

高優先順序佇列工作模式,包括三種:

transactions:只有一個已經開啟了事務的SQL,並且thread_pool_high_prio_tickets不為0,才會進入到高優先順序佇列中,每個連線在thread_pool_high_prio_tickets次被放到優先佇列中後,會移到普通佇列中;

statement:所有連線都被放入高優先順序佇列中;

none:與statement相反,所有連線都被放入低優先順序佇列中。

transactions transactions/statement/none

thread_pool_high_prio_tickets

transactions工作模式下,給每個連線的授予的tickets大小

UINT_MAX

(0,UINT_MAX)

threadpool_workaround_epoll_bug

是否繞過linux2.x中的epoll bug,該bug在linux 3中修復

no

no/yes

下面對TXSQL新增的show threadpool status命令展示的相關狀態進行說明:

狀態名狀態說明
groupid 執行緒組id
connection_count 執行緒組使用者連線數
thread_count 執行緒組內工作執行緒數
havelistener 執行緒組當前是否存在listener
active_thread_count 執行緒組內活躍worker數量
waiting_thread_count 執行緒組內等待中的worker數量(呼叫wait_begin的worker)
waiting_threads_size 執行緒組中無網路事件需要處理,進入休眠期等待被喚醒的worker數量(等待thread_pool_idle_timeout秒後自動銷燬)
queue_size 執行緒組普通優先順序佇列長度
high_prio_queue_size 執行緒組高優先順序佇列長度

get_high_prio_queue_num

執行緒組內事件從高優先順序佇列被取走的總次數

get_normal_queue_num

執行緒組內事件從普通優先順序佇列被取走的總次數

create_thread_num

執行緒組內建立的worker執行緒總數

wake_thread_num

執行緒組內從waiting_threads佇列中喚醒的worker總數

oversubscribed_num

執行緒組內worker發現當前執行緒組處於oversubscribed狀態,並且準備進入休眠的次數

mysql_cond_timedwait_num

執行緒組內worker進入waiting_threads佇列的總次數

check_stall_nolistener

執行緒組被timer執行緒check_stall檢查中發現沒有listener的總次數

check_stall_stall

執行緒組被timer執行緒check_stall檢查中被判定為stall狀態的總次數

max_req_latency_us

執行緒組中使用者連線在佇列等待的最長時間(單位毫秒)

conns_timeout_killed

執行緒組中使用者連線因客戶端無新訊息時間超過閾值(net_wait_timeout)被killed的總次數

connections_moved_in

從其他執行緒組中遷入該執行緒組的連線總數

connections_moved_out

從該執行緒組遷出到其他執行緒組的連線總數

connections_moved_from_per_thread

從one-thread-per-connection模式中遷入該執行緒組的連線總數

connections_moved_to_per_thread

從該執行緒組中遷出到one-thread-per-connection模式的連線總數

events_consumed

執行緒組處理過的events總數

average_wait_usecs_in_queue

執行緒組內所有events在佇列中的平均等待時間

「第七部分 總結

本文從背景、原理、架構、實現、引數狀態等方面介紹了percona-執行緒池。此外,還簡單介紹了TXSQL關於執行緒池的動態啟停、負載均衡以及快速斷連等優化。

「第八部分 參考文獻

【參考文獻1】https://www.percona.com/blog/2013/03/16/simcity-outages-traffic-control-and-thread-pool-for-mysql/

【參考文獻2】https://dbaplus.cn/news-11-1989-1.html

【參考文獻3】https://www.percona.com/doc/percona-server/5.7/performance/threadpool.html

【參考文獻4https://mariadb.com/kb/en/thread-pool-in-mariadb/