了解線程安全之前,我們先回顧幾點基礎知識點,是我們後面分析學習的基礎。
從作用域上來說,C語言可以定義4種不同的變量:全局變量,靜态全局變量,局部變量,靜态局部變量。
下面僅從函數作用域的角度分析一下不同的變量,假設所有變量聲明不重名。
全局變量(<code>int gVar;</code>),在函數外聲明。全局變量,所有函數共享,在任何地方出現這個變量名都是指這個變量。
靜态全局變量(<code>static sgVar</code>),其實也是所有函數共享,但是這個會有編譯器的限制,算是編譯器提供的一種功能。
局部變量(函數/塊内的<code>int var;</code>),不共享,函數的多次執行中涉及的這個變量都是互相獨立的,他們隻是重名的不同變量而已。
局部靜态變量(函數中的<code>static int sVar;</code>),本函數間共享,函數的每一次執行中涉及的這個變量都是這個同一個變量。
上面幾種作用域都是從函數的角度來定義作用域的,可以滿足所有我們對單線程程式設計中變量的共享情況。 現在我們來分析一下多線程的情況。
在多線程中,多個線程共享除函數調用棧之外的其他資源。 是以上面幾種作用域從定義來看就變成了。
全局變量,所有函數共享,是以所有的線程共享,不同線程中出現的不同變量都是這同一個變量。
靜态全局變量,所有函數共享,也是所有線程共享。
局部變量,此函數的各次執行中涉及的這個變量沒有聯系,是以,也是各個線程間也是不共享的。
靜态局部變量,本函數間共享,函數的每次執行涉及的這個變量都是同一個變量,是以,各個線程是共享的。
在多線程系統中,程序保留着資源所有權的屬性,而多個并發執行流是執行在程序中運作的線程。 如 Apache2 中的 worker,主要制程序生成多個子程序,每個子程序中包含固定的線程數,各個線程獨立地處理請求。 同樣,為了不在請求到來時再生成線程,MinSpareThreads 和 MaxSpareThreads 設定了最少和最多的空閑線程數; 而 MaxClients 設定了所有子程序中的線程總數。如果現有子程序中的線程總數不能滿足負載,控制程序将派生新的子程序。
當 PHP 運作在如上類似的多線程伺服器時,此時的 PHP 處在多線程的生命周期中。 在一定的時間内,一個程序空間中會存在多個線程,同一程序中的多個線程公用子產品初始化後的全局變量, 如果和 PHP 在 CLI 模式下一樣運作腳本,則多個線程會試圖讀寫一些存儲在程序記憶體空間的公共資源(如在多個線程公用的子產品初始化後的函數外會存在較多的全局變量)。
此時這些線程通路的記憶體位址空間相同,當一個線程修改時,會影響其它線程,這種共享會提高一些操作的速度, 但是多個線程間就産生了較大的耦合,并且當多個線程并發時,就會産生常見的資料一緻性問題或資源競争等并發常見問題, 比如多次運作結果和單線程運作的結果不一樣。如果每個線程中對全局變量、靜态變量隻有讀操作,而無寫操作,則這些個全局變量就是線程安全的,隻是這種情況不太現實。
為解決線程的并發問題,PHP 引入了 TSRM: 線程安全資料總管(Thread Safe Resource Manager)。 TRSM 的實作代碼在 PHP 源碼的 /TSRM 目錄下,調用随處可見,通常,我們稱之為 TSRM 層。 一般來說,TSRM 層隻會在被指明需要的時候才會在編譯時啟用(比如,Apache2+worker MPM,一個基于線程的MPM), 因為 Win32 下的 Apache 來說,是基于多線程的,是以這個層在 Win32 下總是被開啟的。
程序保留着資源所有權的屬性,線程做并發通路,PHP 中引入的 TSRM 層關注的是對共享資源的通路, 這裡的共享資源是線程之間共享的存在于程序的記憶體空間的全局變量。 當 PHP 在單程序模式下時,一個變量被聲明在任何函數之外時,就成為一個全局變量。
首先定義了如下幾個非常重要的全局變量(這裡的全局變量是多線程共享的)。
<code>**tsrm_tls_table</code> 的全拼 thread safe resource manager thread local storage table,用來存放各個線程的 <code>tsrm_tls_entry</code> 連結清單。
<code>tsrm_tls_table_size</code> 用來表示 <code>**tsrm_tls_table</code> 的大小。
<code>id_count</code> 作為全局變量資源的 id 生成器,是全局唯一且遞增的。
<code>*resource_types_table</code> 用來存放全局變量對應的資源。
<code>resource_types_table_size</code> 表示 <code>*resource_types_table</code> 的大小。
其中涉及到兩個關鍵的資料結構 <code>tsrm_tls_entry</code> 和 <code>tsrm_resource_type</code>。
當新增一個全局變量時,<code>id_count</code> 會自增1(加上線程互斥鎖)。然後根據全局變量需要的記憶體、構造函數、析構函數生成對應的資源<code>tsrm_resource_type</code>,存入 <code>*resource_types_table</code>,再根據該資源,為每個線程的所有<code>tsrm_tls_entry</code>節點添加其對應的全局變量。
有了這個大緻的了解,下面通過仔細分析 TSRM 環境的初始化和資源 ID 的配置設定來了解這一完整的過程。
子產品初始化階段,在各個 SAPI main 函數中通過調用 <code>tsrm_startup</code> 來初始化 TSRM 環境。<code>tsrm_startup</code> 函數會傳入兩個非常重要的參數,一個是 <code>expected_threads</code>,表示預期的線程數, 一個是 <code>expected_resources</code>,表示預期的資源數。不同的 SAPI 有不同的初始化值,比如mod_php5,cgi 這些都是一個線程一個資源。
精簡出其中完成的三個重要的工作,初始化了 tsrm_tls_table 連結清單、resource_types_table 數組,以及 id_count。而這三個全局變量是所有線程共享的,實作了線程間的記憶體管理的一緻性。
我們知道初始化一個全局變量時需要使用 ZEND_INIT_MODULE_GLOBALS 宏(下面的數組擴充的例子中會有說明),而其實際則是調用的 ts_allocate_id 函數在多線程環境下申請一個全局變量,然後傳回配置設定的資源 ID。代碼雖然比較多,實際還是比較清晰,下面附帶注解進行說明:
當通過 ts_allocate_id 函數配置設定全局資源 ID 時,PHP 核心會先加上互斥鎖,確定生成的資源 ID 的唯一,這裡鎖的作用是在時間次元将并發的内容變成串行,因為并發的根本問題就是時間的問題。當加鎖以後,id_count 自增,生成一個資源 ID,生成資源 ID 後,就會給目前資源 ID 配置設定存儲的位置,
每一個資源都會存儲在 resource_types_table 中,當一個新的資源被配置設定時,就會建立一個 tsrm_resource_type。
所有 tsrm_resource_type 以數組的方式組成 tsrm_resource_table,其下标就是這個資源的 ID。
其實我們可以将 tsrm_resource_table 看做一個 HASH 表,key 是資源 ID,value 是 tsrm_resource_type 結構(任何一個數組都可以看作一個 HASH 表,如果數組的key 值有意義的話)。
在配置設定了資源 ID 後,PHP 核心會接着周遊所有線程為每一個線程的 tsrm_tls_entry 配置設定這個線程全局變量需要的記憶體空間。
這裡每個線程全局變量的大小在各自的調用處指定(也就是全局變量結構體的大小)。最後對位址存放的全局變量進行初始化。
為此我畫了一張圖予以說明

上圖中 <code>tsrm_tls_table</code> 的元素是如何添加的,連結清單是如何實作的。我們把這個問題先留着,後面會讨論。
每一次的 ts_allocate_id 調用,PHP 核心都會周遊所有線程并為每一個線程配置設定相應資源,
如果這個操作是在PHP生命周期的請求處理階段進行,豈不是會重複調用?
PHP 考慮了這種情況,ts_allocate_id 的調用在子產品初始化時就調用了。
TSRM 啟動後,在子產品初始化過程中會周遊每個擴充的子產品初始化方法,
擴充的全局變量在擴充的實作代碼開頭聲明,在 MINIT 方法中初始化。
其在初始化時會知會 TSRM 申請的全局變量以及大小,這裡所謂的知會操作其實就是前面所說的 ts_allocate_id 函數。
TSRM 在記憶體池中配置設定并注冊,然後将資源ID傳回給擴充。
以标準的數組擴充為例,首先會聲明目前擴充的全局變量。
然後在子產品初始化時會調用全局變量初始化宏初始化 array,比如配置設定記憶體空間操作。
這裡的聲明和初始化操作都是區分ZTS和非ZTS。
對于非ZTS的情況,直接聲明變量,初始化變量;對于ZTS情況,PHP核心會添加TSRM,不再是聲明全局變量,而是用ts_rsrc_id代替,初始化時也不再是初始化變量,而是調用ts_allocate_id函數在多線程環境中給目前這個子產品申請一個全局變量并傳回資源ID。其中,資源ID變量名由子產品名加global_id組成。
如果要調用目前擴充的全局變量,則使用:ARRAYG(v),這個宏的定義:
如果是非ZTS則直接調用全局變量的屬性字段,如果是ZTS,則需要通過TSRMG擷取變量。
TSRMG的定義:
去掉這一堆括号,TSRMG宏的意思就是從tsrm_ls中按資源ID擷取全局變量,并傳回對應變量的屬性字段。
那麼現在的問題是這個 <code>tsrm_ls</code> 從哪裡來的?
<code>tsrm_ls</code> 通過 <code>ts_resource(0)</code> 初始化。展開實際最後調用的是 <code>ts_resource_ex(0,NULL)</code> 。下面将 <code>ts_resource_ex</code> 一些宏展開,線程以 <code>pthread</code> 為例。
而 <code>allocate_new_resource</code> 則是為新的線程在對應的連結清單中配置設定記憶體,并且将所有的全局變量都加入到其 <code>storage</code> 指針數組中。
上面有一個知識點,Thread Local Storage ,現在有一全局變量 tls_key,所有線程都可以使用它,改變它的值。
表面上看起來這是一個全局變量,所有線程都可以使用它,而它的值在每一個線程中又是單獨存儲的。這就是線程本地存儲的意義。
那麼如何實作線程本地存儲呢?
需要聯合 <code>tsrm_startup</code>, <code>ts_resource_ex</code>, <code>allocate_new_resource</code> 函數并配以注釋一起舉例說明:
在了解了 <code>tsrm_tls_table</code> 數組和其中連結清單的建立之後,再看 <code>ts_resource_ex</code> 函數中調用的這個傳回宏
就是根據傳入 <code>tsrm_tls_entry</code> 和 <code>storage</code> 的數組下标 <code>offset</code> ,然後傳回該全局變量在該線程的 <code>storage</code>數組中的位址。到這裡就明白了在多線程中擷取全局變量宏 <code>TSRMG</code> 宏定義了。
其實這在我們寫擴充的時候會經常用到:
NOTICE 寫擴充的時候可能很多同學都分不清楚到底用哪一個,通過宏展開我們可以看到,他們分别是帶逗号和不帶逗号,以及申明及調用,那麼英語中“D"就是代表:Define,而 後面的"C"是 Comma,逗号,前面的"C"就是Call。
以上為ZTS模式下的定義,非ZTS模式下其定義全部為空。