perlthrtut - Perl 中執行緒教學
本教學說明 Perl 解譯器執行緒(有時稱為 ithreads)的使用方式。在此模式中,每個執行緒在自己的 Perl 解譯器中執行,執行緒之間的任何資料共用都必須明確指定。ithreads 的使用者層級介面使用 threads 類別。
注意:還有另一種較舊的 Perl 執行緒形式,稱為 5.005 模式,它使用 threads 類別。已知此舊模式有問題,已不建議使用,並已移除於版本 5.10。強烈建議您盡快將現有的任何 5.005 執行緒程式碼移轉至新模式。
您可以透過執行 perl -V
並查看 Platform
區段,來查看您擁有哪種(或沒有)執行緒形式。如果您有 useithreads=define
,則您有 ithreads,如果您有 use5005threads=define
,則您有 5.005 執行緒。如果您兩者都沒有,則表示您沒有內建任何執行緒支援。如果您兩者都有,則您有麻煩了。
threads 和 threads::shared 模組包含在 Perl 核心發行版中。此外,它們作為獨立的模組維護在 CPAN 上,因此您可以在那裡查看是否有任何更新。
執行緒是程式中具有單一執行點的控制流程。
聽起來很像處理程序,不是嗎?是的,應該如此。執行緒是處理程序的一部分。每個處理程序至少有一個執行緒,而直到現在,每個執行 Perl 的處理程序只有一個執行緒。不過,在 5.8 中,您可以建立額外的執行緒。我們將向您展示如何、何時以及為何執行此操作。
您可以使用三種基本方式來建構執行緒程式。您選擇哪種模式取決於您需要程式執行什麼。對於許多非平凡的執行緒程式,您需要為程式的不同部分選擇不同的模式。
老闆/工作者模式通常有一個 老闆 執行緒和一個或多個 工作者 執行緒。老闆執行緒收集或產生需要執行的任務,然後將這些任務分配給適當的工作者執行緒。
此模型在 GUI 和伺服器程式中很常見,其中主執行緒會等待某些事件,然後將該事件傳遞給適當的工作執行緒進行處理。事件傳遞後,主執行緒會回到等待另一個事件的狀態。
主執行緒的工作相對較少。雖然任務不一定比其他方法執行得更快,但它往往具有最佳的使用者回應時間。
在工作組模型中,會建立多個執行緒,對不同的資料片段執行本質上相同的工作。它與傳統的並行處理和向量處理器非常相似,其中大量的處理器對許多資料片段執行完全相同的工作。
如果執行程式的系統會將多個執行緒分配到不同的處理器,則此模型特別有用。它也可以用於光線追蹤或渲染引擎,其中個別執行緒可以傳遞中間結果以提供使用者視覺回饋。
管線模型將任務分割成一系列步驟,並將一個步驟的結果傳遞給處理下一個步驟的執行緒。每個執行緒對每個資料片段執行一項工作,並將結果傳遞給下一條線上的執行緒。
如果您有多個處理器,則此模型最有意義,因此兩個或更多執行緒將並行執行,儘管它通常在其他情況下也可能有意義。它傾向於使個別任務保持小而簡單,並允許管線的某些部分阻塞(例如,在 I/O 或系統呼叫上),而其他部分則繼續執行。如果您在不同的處理器上執行管線的不同部分,您也可以利用每個處理器上的快取。
此模型也適用於遞迴程式設計的形式,其中它不是讓子常式呼叫自身,而是建立另一個執行緒。質數和費氏數列產生器都很好地對應到此管線模型形式。(稍後會提供質數產生器的版本。)
如果您有其他執行緒實作的經驗,您可能會發現事情並不如您預期的那樣。在處理 Perl 執行緒時,請務必記住,對於 X 的所有值,Perl 執行緒並非 X 執行緒。它們不是 POSIX 執行緒、DecThreads、Java 的 Green 執行緒或 Win32 執行緒。它們有相似之處,而且廣義的概念相同,但如果您開始尋找實作細節,您可能會感到失望或困惑。可能兩者都有。
這並不是說 Perl 執行緒與以前出現的一切完全不同。並非如此。Perl 的執行緒模型歸功於其他執行緒模型,尤其是 POSIX。儘管 Perl 不是 C,但 Perl 執行緒也不是 POSIX 執行緒。因此,如果您發現自己在尋找互斥鎖或執行緒優先順序,那麼是時候稍微退後一步,想想您想做什麼以及 Perl 如何做到這一點。
不過,重要的是要記住,除非作業系統的執行緒允許,否則 Perl 執行緒無法神奇地執行任務。因此,如果您的系統在 sleep()
上封鎖整個程序,Perl 通常也會封鎖。
Perl 執行緒不同。
執行緒的加入大幅改變了 Perl 的內部結構。對於撰寫具有 XS 程式碼或外部函式庫的模組的人來說,這會帶來影響。不過,由於 Perl 資料預設不會在執行緒之間共用,因此 Perl 模組有很高的機率是執行緒安全的,或是可以輕易地變成執行緒安全。未標記為執行緒安全的模組應在用於生產程式碼之前進行測試或程式碼檢閱。
您可能使用的模組並非都是執行緒安全的,而且您應始終假設模組是不安全的,除非文件另有說明。這包括作為核心一部分所發布的模組。執行緒是一個相對較新的功能,甚至連一些標準模組都不是執行緒安全的。
即使模組是執行緒安全的,也不表示該模組已最佳化,以便與執行緒順利運作。模組可以重新撰寫,以利用執行緒化 Perl 中的新功能,以提升執行緒化環境中的效能。
如果您出於某種原因使用不安全的模組,您可以透過僅從一個執行緒使用該模組來保護自己。如果您需要多個執行緒來存取此類模組,您可以使用旗標和大量的程式設計規範來控制對它的存取。旗標在「基本旗標」中進行說明。
另請參閱「系統函式庫的執行緒安全性」。
threads 模組提供撰寫執行緒化程式所需的基本函式。在以下各節中,我們將介紹基礎知識,告訴您建立執行緒化程式所需執行的動作。之後,我們將探討 threads 模組中一些讓執行緒化程式設計更輕鬆的功能。
執行緒支援是 Perl 編譯時期選項。這是 Perl 在您的網站建置時啟用或停用的功能,而不是在編譯您的程式時啟用或停用。如果您的 Perl 未在啟用執行緒支援的情況下編譯,則任何嘗試使用執行緒的動作都會失敗。
您的程式可以使用 Config 模組來檢查執行緒是否已啟用。如果您的程式無法在沒有執行緒的情況下執行,您可以說一些像
use Config;
$Config{useithreads} or
die('Recompile Perl with threads to run this program.');
使用可能執行緒模組的可能執行緒程式碼可能如下
use Config;
use MyMod;
BEGIN {
if ($Config{useithreads}) {
# We have threads
require MyMod_threaded;
import MyMod_threaded;
} else {
require MyMod_unthreaded;
import MyMod_unthreaded;
}
}
由於同時使用和不使用執行緒的程式碼通常很混亂,因此最好將特定執行緒的程式碼隔離在自己的模組中。在上面的範例中,這就是 MyMod_threaded
的用途,而且只有在我們在執行緒 Perl 上執行時才會匯入。
在實際情況中,應注意在程式結束之前所有執行緒都已執行完畢。為求簡潔,這些範例中沒有注意這一點。以原樣執行這些範例會產生錯誤訊息,通常是由於程式結束時仍有執行緒在執行所造成。您不應因此感到驚慌。
threads 模組提供建立新執行緒所需的工具。與任何其他模組一樣,您需要告訴 Perl 您要使用它;use threads;
會匯入建立基本執行緒所需的所有部分。
建立執行緒最簡單、最直接的方法是使用 create()
use threads;
my $thr = threads->create(\&sub1);
sub sub1 {
print("In the thread\n");
}
create()
方法會取得對子常式的參考,並建立一個新的執行緒,該執行緒會在參考的子常式中開始執行。然後,控制權會同時傳遞給子常式和呼叫者。
如果需要,您的程式可以在執行緒啟動時將參數傳遞給子常式。只需將參數清單包含在 threads->create()
呼叫中,如下所示
use threads;
my $Param3 = 'foo';
my $thr1 = threads->create(\&sub1, 'Param 1', 'Param 2', $Param3);
my @ParamList = (42, 'Hello', 3.14);
my $thr2 = threads->create(\&sub1, @ParamList);
my $thr3 = threads->create(\&sub1, qw(Param1 Param2 Param3));
sub sub1 {
my @InboundParameters = @_;
print("In the thread\n");
print('Got parameters >', join('<>',@InboundParameters), "<\n");
}
最後一個範例說明了執行緒的另一個功能。您可以使用同一個子常式產生多個執行緒。每個執行緒都會執行同一個子常式,但會在不同的執行緒中執行,並具有不同的環境和潛在不同的引數。
new()
是 create()
的同義詞。
由於執行緒也是子常式,因此它們可以傳回值。若要等待執行緒結束並擷取它可能傳回的任何值,您可以使用 join()
方法
use threads;
my ($thr) = threads->create(\&sub1);
my @ReturnData = $thr->join();
print('Thread returned ', join(', ', @ReturnData), "\n");
sub sub1 { return ('Fifty-six', 'foo', 2); }
在上面的範例中,join()
方法會在執行緒結束時立即傳回。除了等待執行緒完成並收集執行緒可能已傳回的任何值之外,join()
還會執行執行緒所需的任何作業系統清理。這種清理可能很重要,特別是對於會產生大量執行緒的長時間執行程式。如果您不需要傳回值,也不想等待執行緒完成,您應該改為呼叫 detach()
方法,如下所述。
注意:在上面的範例中,執行緒會傳回清單,因此執行緒建立呼叫必須在清單內容中進行(即,my ($thr)
)。請參閱 "$thr->join()" in threads 和 "THREAD CONTEXT" in threads,以取得執行緒內容和傳回值的更多詳細資料。
join()
會執行三件事:它會等待執行緒結束、清除執行緒,並傳回執行緒產生的任何資料。但是,如果你對執行緒的傳回值沒有興趣,而且不在乎執行緒何時結束,該怎麼辦?你只要在執行緒完成後清除執行緒即可。
在這種情況下,請使用 detach()
方法。執行緒一旦分離,就會執行到結束為止;然後 Perl 會自動清除執行緒。
use threads;
my $thr = threads->create(\&sub1); # Spawn the thread
$thr->detach(); # Now we officially don't care any more
sleep(15); # Let thread run for awhile
sub sub1 {
my $count = 0;
while (1) {
$count++;
print("\$count is $count\n");
sleep(1);
}
}
執行緒一旦分離,就無法加入,而且它可能產生的任何傳回資料(如果執行緒已完成並等待加入)都會遺失。
detach()
也可以作為類別方法呼叫,以允許執行緒自行分離
use threads;
my $thr = threads->create(\&sub1);
sub sub1 {
threads->detach();
# Do more work
}
使用執行緒時,必須小心確保所有執行緒都有機會執行到完成,假設這是你想要的。
終止處理程序的動作會終止所有正在執行的執行緒。die() 和 exit() 具有此特性,而且 Perl 會在主執行緒結束時執行 exit,甚至可能是透過隱含的方式,例如從程式碼的結尾中脫落,即使這不是你想要的。
以下程式碼為此情況的範例,它會印出訊息「Perl exited with active threads: 2 running and unjoined」
use threads;
my $thr1 = threads->new(\&thrsub, "test1");
my $thr2 = threads->new(\&thrsub, "test2");
sub thrsub {
my ($message) = @_;
sleep 1;
print "thread $message\n";
}
但是,當在結尾新增以下列時
$thr1->join();
$thr2->join();
它會印出兩行輸出,這可能是更有用的結果。
現在我們已經介紹執行緒的基礎知識,接下來的主題是:資料。執行緒會為資料存取帶來一些複雜性,這是非執行緒程式永遠不需要擔心的。
Perl ithreads 與舊版的 5.005 執行緒,或其他大多數執行緒系統之間最大的差異在於,預設情況下,資料不會共用。當建立新的 Perl 執行緒時,與目前執行緒相關的所有資料都會複製到新執行緒,並且隨後僅限於該新執行緒使用!這感覺類似於 Unix 程序分岔時所發生的情況,但這種情況下,資料僅複製到同一個程序中記憶體的不同部分,而不是發生實際分岔。
然而,若要使用執行緒,通常會希望執行緒彼此共用至少一些資料。這會透過 threads::shared 模組和 :shared
屬性來完成
use threads;
use threads::shared;
my $foo :shared = 1;
my $bar = 1;
threads->create(sub { $foo++; $bar++; })->join();
print("$foo\n"); # Prints 2 since $foo is shared
print("$bar\n"); # Prints 1 since $bar is not shared
如果是共用陣列,陣列中的所有元素都會共用,如果是共用雜湊,則所有金鑰和值都會共用。這會對可指派給共用陣列和雜湊元素的內容設下限制:僅允許簡單值或共用變數的參考,這是為了避免私人變數意外地變成共用變數。錯誤的指派會導致執行緒終止。例如
use threads;
use threads::shared;
my $var = 1;
my $svar :shared = 2;
my %hash :shared;
... create some threads ...
$hash{a} = 1; # All threads see exists($hash{a})
# and $hash{a} == 1
$hash{a} = $var; # okay - copy-by-value: same effect as previous
$hash{a} = $svar; # okay - copy-by-value: same effect as previous
$hash{a} = \$svar; # okay - a reference to a shared variable
$hash{a} = \$var; # This will die
delete($hash{a}); # okay - all threads will see !exists($hash{a})
請注意,共用變數保證如果兩個或以上的執行緒同時嘗試修改它,變數的內部狀態不會損毀。然而,除此之外並沒有其他保證,如下一節所述。
雖然執行緒帶來了一組新的有用工具,但也帶來了一些陷阱。其中一個陷阱是競爭狀態
use threads;
use threads::shared;
my $x :shared = 1;
my $thr1 = threads->create(\&sub1);
my $thr2 = threads->create(\&sub2);
$thr1->join();
$thr2->join();
print("$x\n");
sub sub1 { my $foo = $x; $x = $foo + 1; }
sub sub2 { my $bar = $x; $x = $bar + 1; }
您認為 $x
會是什麼?答案很遺憾是取決於。sub1()
和 sub2()
都會存取全域變數 $x
,一次讀取,一次寫入。$x
可能會是 2 或 3,這取決於許多因素,從執行緒實作的排程演算法到月亮的盈虧。
競爭狀態是由於未同步存取共用資料所造成的。在沒有明確同步的情況下,無法確定在您存取共用資料和更新共用資料之間,是否對共用資料做過任何變更。即使是這個簡單的程式碼片段也可能發生錯誤
use threads;
my $x :shared = 2;
my $y :shared;
my $z :shared;
my $thr1 = threads->create(sub { $y = $x; $x = $y + 1; });
my $thr2 = threads->create(sub { $z = $x; $x = $z + 1; });
$thr1->join();
$thr2->join();
兩個執行緒都存取 $x
。每個執行緒都可能在任何時間點中斷,或以任何順序執行。最後,$x
可能會是 3 或 4,$y
和 $z
都可能為 2 或 3。
即使是 $x += 5
或 $x++
也無法保證是原子性的。
每當您的程式存取其他執行緒可以存取的資料或資源時,您都必須採取步驟來協調存取,否則會冒資料不一致和競爭狀態的風險。請注意,Perl 會保護其內部結構免於您的競爭狀態,但不會保護您免於您自己。
Perl 提供許多機制來協調它們之間與它們的資料互動,以避免競爭條件之類的問題。其中一些機制設計成類似於執行緒程式庫(例如 pthreads
)中使用的常見技術;其他則是 Perl 特有的。標準技術通常笨拙且難以正確使用(例如條件等待)。在可能的情況下,通常較容易使用 Perl 技術(例如佇列),這可以消除一些艱難的工作。
lock()
函數會取得一個共用變數並對其加鎖。在持有鎖定的執行緒解除鎖定之前,沒有其他執行緒可以鎖定該變數。當鎖定的執行緒退出包含呼叫 lock()
函數的區塊時,會自動解除鎖定。使用 lock()
很簡單:此範例有幾個執行緒並行執行一些計算,並偶爾更新執行中的總計
use threads;
use threads::shared;
my $total :shared = 0;
sub calc {
while (1) {
my $result;
# (... do some calculations and set $result ...)
{
lock($total); # Block until we obtain the lock
$total += $result;
} # Lock implicitly released at end of scope
last if $result == 0;
}
}
my $thr1 = threads->create(\&calc);
my $thr2 = threads->create(\&calc);
my $thr3 = threads->create(\&calc);
$thr1->join();
$thr2->join();
$thr3->join();
print("total=$total\n");
lock()
會封鎖執行緒,直到要鎖定的變數可用。當 lock()
傳回時,您的執行緒可以確定沒有其他執行緒可以在包含鎖定的區塊結束之前鎖定該變數。
請務必注意,鎖定不會阻止存取有問題的變數,只會封鎖鎖定嘗試。這符合 Perl 長期以來的禮貌程式設計傳統,以及 flock()
提供的建議性檔案鎖定。
您可以鎖定陣列和雜湊,以及純量。不過,鎖定陣列不會封鎖後續對陣列元素的鎖定,只會封鎖對陣列本身的鎖定嘗試。
鎖定是遞迴的,這表示執行緒可以鎖定變數多次。鎖定會持續到對變數的最外層 lock()
超出範圍為止。例如
my $x :shared;
doit();
sub doit {
{
{
lock($x); # Wait for lock
lock($x); # NOOP - we already have the lock
{
lock($x); # NOOP
{
lock($x); # NOOP
lockit_some_more();
}
}
} # *** Implicit unlock here ***
}
}
sub lockit_some_more {
lock($x); # NOOP
} # Nothing happens here
請注意,沒有 unlock()
函數 - 解除變數鎖定的唯一方法是讓它超出範圍。
鎖定可以用来保護鎖定變數中包含的資料,或者可以用来保護其他東西,例如程式碼區段。在後一種情況下,有問題的變數不包含任何有用的資料,並且只存在於鎖定的目的。在這方面,變數的行為類似於傳統執行緒程式庫中的互斥鎖和基本信號量。
鎖定是同步存取資料的便利工具,正確使用它們是安全共用資料的關鍵。不幸的是,鎖定並非沒有危險,特別是在涉及多個鎖定的時候。考慮以下程式碼
use threads;
my $x :shared = 4;
my $y :shared = 'foo';
my $thr1 = threads->create(sub {
lock($x);
sleep(20);
lock($y);
});
my $thr2 = threads->create(sub {
lock($y);
sleep(20);
lock($x);
});
此程式可能會一直掛起,直到您將它終止。它不會掛起的唯一情況是,如果兩個執行緒之一首先取得兩個鎖定。保證會掛起的版本較為複雜,但原理相同。
第一個執行緒會取得對 $x
的鎖定,然後,在第二個執行緒可能已經有時間執行一些工作的暫停期間,嘗試取得對 $y
的鎖定。同時,第二個執行緒取得對 $y
的鎖定,然後稍後嘗試取得對 $x
的鎖定。兩個執行緒的第二次鎖定嘗試都會被封鎖,每個執行緒都在等待另一個執行緒釋放其鎖定。
此狀態稱為死結,當兩個或多個執行緒嘗試取得其他執行緒擁有的資源鎖定時,就會發生此狀態。每個執行緒都會被封鎖,等待另一個執行緒釋放資源的鎖定。然而,這永遠不會發生,因為擁有資源的執行緒本身正在等待鎖定被釋放。
有許多方法可以處理此類問題。最好的方法是始終讓所有執行緒以完全相同的順序取得鎖定。例如,如果您鎖定變數 $x
、$y
和 $z
,請務必先鎖定 $x
,再鎖定 $y
,最後鎖定 $z
。最好也盡量縮短鎖定時間,以將死結的風險降至最低。
以下所述的其他同步原語可能會遭遇類似的問題。
佇列是一個特殊的執行緒安全物件,讓您可以將資料放入一端,並從另一端取出,而不用擔心同步問題。它們相當簡單,如下所示
use threads;
use Thread::Queue;
my $DataQueue = Thread::Queue->new();
my $thr = threads->create(sub {
while (my $DataElement = $DataQueue->dequeue()) {
print("Popped $DataElement off the queue\n");
}
});
$DataQueue->enqueue(12);
$DataQueue->enqueue("A", "B", "C");
sleep(10);
$DataQueue->enqueue(undef);
$thr->join();
您可以使用 Thread::Queue->new()
建立佇列。然後,您可以使用 enqueue()
將純量清單新增到尾端,並使用 dequeue()
從前端彈出純量。佇列沒有固定的大小,可以根據需要擴充,以容納推入其中的所有資料。
如果佇列為空,dequeue()
會封鎖,直到另一個執行緒將資料排入佇列。這使得佇列非常適合事件迴圈和其他執行緒之間的通訊。
信號量是一種通用的鎖定機制。在最基本的型態中,它們的行為非常類似於可鎖定的純量,但它們無法儲存資料,而且必須明確解鎖。在進階型態中,它們的作用類似於一種計數器,並允許多個執行緒在任何時間點擁有「鎖定」。
信號量有兩個方法,down()
和 up()
:down()
會遞減資源計數,而 up()
會遞增資源計數。如果信號量的目前計數會遞減至低於零,則呼叫 down()
會被封鎖。此程式提供一個簡短的示範
use threads;
use Thread::Semaphore;
my $semaphore = Thread::Semaphore->new();
my $GlobalVariable :shared = 0;
$thr1 = threads->create(\&sample_sub, 1);
$thr2 = threads->create(\&sample_sub, 2);
$thr3 = threads->create(\&sample_sub, 3);
sub sample_sub {
my $SubNumber = shift(@_);
my $TryCount = 10;
my $LocalCopy;
sleep(1);
while ($TryCount--) {
$semaphore->down();
$LocalCopy = $GlobalVariable;
print("$TryCount tries left for sub $SubNumber "
."(\$GlobalVariable is $GlobalVariable)\n");
sleep(2);
$LocalCopy++;
$GlobalVariable = $LocalCopy;
$semaphore->up();
}
}
$thr1->join();
$thr2->join();
$thr3->join();
子常式的三個呼叫都同步執行。然而,信號量確保一次只有一個執行緒存取全域變數。
預設情況下,信號量會像鎖定一樣,一次只允許一個執行緒 down()
信號量。然而,信號量還有其他用途。
每個信號量都有一個計數器附加在其上。預設情況下,信號量會在計數器設定為 1 的情況下建立,down()
會將計數器遞減 1,而 up()
會遞增 1。然而,我們可以透過傳入不同的值,來覆寫其中任何一個或所有預設值
use threads;
use Thread::Semaphore;
my $semaphore = Thread::Semaphore->new(5);
# Creates a semaphore with the counter set to five
my $thr1 = threads->create(\&sub1);
my $thr2 = threads->create(\&sub1);
sub sub1 {
$semaphore->down(5); # Decrements the counter by five
# Do stuff here
$semaphore->up(5); # Increment the counter by five
}
$thr1->detach();
$thr2->detach();
如果 down()
嘗試將計數器遞減到零以下,它會阻塞,直到計數器夠大。請注意,儘管可以建立開始計數為零的信號量,但任何 up()
或 down()
總是會將計數器至少變更一,因此 $semaphore->down(0)
與 $semaphore->down(1)
相同。
當然,問題是為什麼你要這樣做?為什麼要建立開始計數不為一的信號量,或者為什麼要將它遞減或遞增超過一?答案是資源可用性。許多您想要管理存取的資源可以同時安全地由多個執行緒使用。
例如,讓我們來看看 GUI 驅動的程式。它有一個信號量,用於同步存取顯示,因此一次只有一個執行緒正在繪製。很方便,但當然您不希望任何執行緒在事情適當地設定好之前開始繪製。在這種情況下,您可以建立一個計數器設定為零的信號量,並在事情準備好繪製時將它遞增。
計數器大於一的信號量對於建立配額也很有用。例如,假設您有許多執行緒可以同時執行 I/O。您不希望所有執行緒同時讀取或寫入,因為這可能會淹沒您的 I/O 通道,或耗盡您的程序的檔案句柄配額。您可以使用初始化為您任何時候想要的並行 I/O 要求(或開啟檔案)數量的信號量,並讓您的執行緒安靜地阻塞和解除阻塞自己。
在執行緒需要一次查看或傳回多個資源的情況下,較大的遞增或遞減會很方便。
函式 cond_wait()
和 cond_signal()
可以與鎖定結合使用,以通知合作執行緒資源已可用。它們的使用方式與在 pthreads
中找到的函式非常相似。然而,對於大多數目的,佇列更易於使用且更直觀。請參閱 threads::shared 以取得更多詳細資訊。
有時您可能會發現讓執行緒明確放棄 CPU 給另一個執行緒很有用。您可能正在做一些處理器密集的工作,並希望確保使用者介面執行緒會被頻繁呼叫。無論如何,有時您可能希望執行緒放棄處理器。
Perl 的執行緒套件提供了 yield()
函式來執行此操作。yield()
非常簡單,它的作用如下
use threads;
sub loop {
my $thread = shift;
my $foo = 50;
while($foo--) { print("In thread $thread\n"); }
threads->yield();
$foo = 50;
while($foo--) { print("In thread $thread\n"); }
}
my $thr1 = threads->create(\&loop, 'first');
my $thr2 = threads->create(\&loop, 'second');
my $thr3 = threads->create(\&loop, 'third');
重要的是要記住,yield()
只是放棄 CPU 的提示,它取決於您的硬體、作業系統和執行緒函式庫實際發生的事情。在許多作業系統上,yield() 是無效操作。因此,重要的是要注意,不應圍繞 yield()
呼叫來建立執行緒的排程。它可能在您的平台上執行,但它不會在另一個平台上執行。
我們已經介紹了 Perl 執行緒套件的要角,有了這些工具,您應該可以順利撰寫執行緒程式碼和套件。有一些有用的部分沒有真正適合放在其他任何地方。
threads->self()
類別方法提供您的程式取得目前執行緒物件的方法。您可以使用此物件,就像從執行緒建立中傳回的物件一樣。
tid()
是執行緒物件方法,傳回物件所代表執行緒的執行緒 ID。執行緒 ID 為整數,程式中的主執行緒為 0。目前 Perl 會指派每個在您的程式中建立的執行緒一個獨特的 TID,指派第一個建立的執行緒 TID 為 1,並為每個新建立的執行緒增加 TID 1。當用作類別方法時,執行緒可以使用 threads->tid()
取得自己的 TID。
equal()
方法會取得兩個執行緒物件,如果物件代表相同的執行緒,則傳回 true,否則傳回 false。
執行緒物件也有一個超載的 ==
比較,讓您可以對它們進行比較,就像對一般物件一樣。
threads->list()
傳回執行緒物件清單,每個目前正在執行且未分離的執行緒一個。對於許多事情來說都很方便,包括在您的程式結束時進行清理(當然,從 Perl 主執行緒)。
# Loop through all the threads
foreach my $thr (threads->list()) {
$thr->join();
}
如果在 Perl 主執行緒結束時有些執行緒尚未執行完畢,Perl 會警告您並終止,因為 Perl 無法在其他執行緒執行時自行清理。
注意:Perl 主執行緒(執行緒 0)處於分離狀態,因此不會出現在 threads->list()
傳回的清單中。
感到困惑了嗎?是時候透過範例程式展示我們已介紹的一些內容。這個程式使用執行緒尋找質數。
1 #!/usr/bin/perl
2 # prime-pthread, courtesy of Tom Christiansen
3
4 use v5.36;
5
6 use threads;
7 use Thread::Queue;
8
9 sub check_num ($upstream, $cur_prime) {
10 my $kid;
11 my $downstream = Thread::Queue->new();
12 while (my $num = $upstream->dequeue()) {
13 next unless ($num % $cur_prime);
14 if ($kid) {
15 $downstream->enqueue($num);
16 } else {
17 print("Found prime: $num\n");
18 $kid = threads->create(\&check_num, $downstream, $num);
19 if (! $kid) {
20 warn("Sorry. Ran out of threads.\n");
21 last;
22 }
23 }
24 }
25 if ($kid) {
26 $downstream->enqueue(undef);
27 $kid->join();
28 }
29 }
30
31 my $stream = Thread::Queue->new(3..1000, undef);
32 check_num($stream, 2);
此程式使用管線模型來產生質數。管線中的每個執行緒都有輸入佇列,提供要檢查的數字、它負責的質數,以及它將檢查失敗的數字漏斗輸出的輸出佇列。如果執行緒有檢查失敗的數字,而且沒有子執行緒,則執行緒一定找到新的質數。在這種情況下,會為該質數建立新的子執行緒,並卡在管線的最後。
這聽起來可能比實際情況更令人困惑,因此讓我們逐一檢視此程式,了解它在做什麼。(對於可能試圖確切記住質數是什麼的人來說,質數是只能被本身和 1 整除的數字。)
大部分的工作是由 check_num()
子常式完成,它會參考其輸入佇列和它負責的質數。我們建立新的佇列(第 11 行),並保留一個純量,供我們稍後可能建立的執行緒使用(第 10 行)。
第 12 行到第 24 行的 while 迴圈會從輸入佇列取得純量,並針對此執行緒負責的質數進行檢查。第 13 行檢查當我們將待檢查數字除以我們的質數時,是否有餘數。如果有,則該數字一定無法被我們的質數整除,因此我們需要將它傳遞給我們建立的下一執行緒(第 15 行),或者如果我們尚未建立,則建立新的執行緒。
建立新執行緒的程式碼在第 18 行。我們將我們建立的佇列參考傳遞給它,以及我們找到的質數。在第 19 行到第 22 行,我們檢查以確保我們的執行緒已建立,如果沒有,我們會停止檢查佇列中任何剩餘的數字。
最後,一旦迴圈終止(因為我們在佇列中取得 0 或 undef
,這表示終止的通知),我們會將通知傳遞給我們的子執行緒,如果我們建立了子執行緒,則會等待它結束(第 25 行和第 28 行)。
同時,回到主執行緒,我們首先建立一個佇列(第 31 行),並將所有數字從 3 到 1000 排隊進行檢查,加上一個終止通知。然後,我們所要做的就是將佇列和第一個質數傳遞給 check_num()
子常式(第 32 行),這樣就能開始運作。
這就是它的運作方式。這很簡單;與許多 Perl 程式一樣,解釋比程式長很多。
從作業系統觀點來看,執行緒實作的一些背景知識。執行緒有三個基本類別:使用者模式執行緒、核心執行緒和多處理器核心執行緒。
使用者模式執行緒是完全存在於程式及其函式庫中的執行緒。在此模型中,作業系統對執行緒一無所知。就作業系統而言,您的處理程序只是一個處理程序。
這是實作執行緒最簡單的方法,也是大多數作業系統的起點。最大的缺點是,由於作業系統對執行緒一無所知,如果一個執行緒會封鎖,則所有執行緒都會封鎖。典型的封鎖活動包括大多數系統呼叫、大多數 I/O,以及像 sleep()
之類的東西。
核心執行緒是執行緒演進的下一步。作業系統知道核心執行緒,並為它們做出調整。核心執行緒和使用者模式執行緒之間的主要區別在於封鎖。使用核心執行緒時,會封鎖單一執行緒的事物不會封鎖其他執行緒。使用者模式執行緒並非如此,其中核心在處理程序層級封鎖,而不是在執行緒層級封鎖。
這是向前邁進一大步,可以讓執行緒程式在非執行緒程式上大幅提升效能。例如,執行封鎖執行 I/O 的執行緒不會封鎖執行其他作業的執行緒。不過,每個處理程序一次仍然只有一個執行緒在執行,無論系統有多少個 CPU。
由於核心執行緒可以在任何時候中斷執行緒,因此它們會揭露您在程式中可能做出的某些隱式鎖定假設。例如,如果 $x
對其他執行緒可見,則像 $x = $x + 2
這樣簡單的事情在使用核心執行緒時可能會表現得難以預測,因為另一個執行緒可能在從右側取得 $x
和儲存新值的時間之間變更了 $x
。
多處理器核心執行緒是執行緒支援的最後一步。在具有多個 CPU 的機器上使用多處理器核心執行緒時,作業系統可能會排程兩個或更多執行緒在不同的 CPU 上同時執行。
由於會同時執行多個執行緒,這可以大幅提升執行緒程式的效能。不過,作為權衡,任何可能未顯示在基本核心執行緒中的那些惱人的同步問題都會猛烈出現。
除了作業系統參與執行緒的不同層級外,不同的作業系統(以及特定作業系統的不同執行緒實作)也會以不同的方式將 CPU 週期分配給執行緒。
合作式多工處理系統會讓執行中的執行緒在發生下列兩件事之一時放棄控制權。如果執行緒呼叫讓步函式,它就會放棄控制權。如果執行緒執行會導致它封鎖的動作,例如執行 I/O,它也會放棄控制權。在合作式多工處理實作中,如果一個執行緒選擇,它可以讓所有其他執行緒都無法使用 CPU 時間。
搶先式多工處理系統會在系統決定接下來要執行哪個執行緒時,定期中斷執行緒。在搶先式多工處理系統中,一個執行緒通常不會獨佔 CPU。
在某些系統上,可以同時執行合作式和搶先式執行緒。(以即時優先權執行的執行緒通常會以合作方式運作,例如,以一般優先權執行的執行緒會以搶先方式運作。)
大多數現代作業系統現在都支援搶先式多工處理。
在比較 Perl 的 ithreads 與其他執行緒模型時,要記住的最重要的事情是,對於每個建立的新執行緒,都必須複製父執行緒的所有變數和資料的完整副本。因此,執行緒建立可能會相當昂貴,無論是在記憶體使用量或建立時間方面。減少這些成本的理想方式是擁有數量相對較少且使用時間較長的執行緒,而且所有執行緒都相當早建立(在基礎執行緒累積過多資料之前)。當然,這並不總是可行,因此必須做出妥協。然而,在建立執行緒後,它的效能和額外記憶體使用量應該與一般程式碼相差無幾。
另外請注意,在目前的實作中,共用變數會使用稍微多一點的記憶體,而且比一般變數稍慢。
請注意,雖然執行緒本身是獨立的執行執行緒,而且 Perl 資料是執行緒私有的(除非明確共用),但執行緒會影響程序範圍狀態,影響所有執行緒。
最常見的範例是使用 chdir()
變更目前的作業目錄。一個執行緒呼叫 chdir()
,所有執行緒的作業目錄都會變更。
chroot()
是程序範圍變更更極端的範例:所有執行緒的根目錄都會變更,而且沒有執行緒可以復原它(與 chdir()
相反)。
程序範圍變更的其他範例包括 umask()
以及變更 uid 和 gid。
正在考慮混合使用 fork()
和執行緒嗎?請躺下來,等到這種感覺過去。請注意,fork()
的語意會因平台而異。例如,有些 Unix 系統會將所有目前的執行緒複製到子程序中,而有些只會複製呼叫 fork()
的執行緒。您已受到警告!
同樣地,混合信號和執行緒可能會產生問題。實作會因平台而異,而且即使是 POSIX 語意也可能不是您預期的(而 Perl 甚至沒有提供完整的 POSIX API)。例如,沒有辦法保證傳送給多執行緒 Perl 應用程式的信號會被任何特定執行緒攔截。(不過,最近新增的功能確實提供了在執行緒之間傳送信號的能力。請參閱執行緒中的「執行緒信號傳遞」以取得更多詳細資料。)
各種函式庫呼叫是否具備執行緒安全性不受 Perl 控制。經常出現執行緒安全性問題的呼叫包括:localtime()
、gmtime()
、擷取使用者、群組和網路資訊的函式(例如 getgrent()
、gethostent()
、getnetent()
等)、readdir()
、rand()
和 srand()
。一般來說,會依賴於某些全域外部狀態的呼叫。
如果編譯系統 Perl 的系統具有此類呼叫的執行緒安全變體,則會使用它們。除此之外,Perl 會受到呼叫的執行緒安全性或非安全性影響。請參閱您的 C 函式庫呼叫文件。
在某些平台上,如果結果緩衝區太小,執行緒安全函式庫介面可能會失敗(例如,使用者群組資料庫可能很大,而可重新進入介面可能必須攜帶這些資料庫的完整快照)。Perl 會從一個小緩衝區開始,但會持續重試並擴充結果緩衝區,直到結果符合為止。如果您因為安全性或記憶體消耗的原因而認為這種無限制的擴充很糟糕,您可以重新編譯 Perl,並將 PERL_REENTRANT_MAXSIZE
定義為您允許的最大位元組數。
一個完整的執行緒教學可以寫成一本書(而且已經寫過很多次了),但透過我們在這個簡介中介紹的內容,您應該已經在成為執行緒 Perl 專家的道路上邁進一大步了。
執行緒的註解 POD:https://web.archive.org/web/20171028020148/http://annocpan.org/?mode=search&field=Module&name=threads
CPAN 上 執行緒 的最新版本:https://metacpan.org/pod/threads
threads::shared 的註解 POD:https://web.archive.org/web/20171028020148/http://annocpan.org/?mode=search&field=Module&name=threads%3A%3Ashared
CPAN 上 threads::shared 的最新版本:https://metacpan.org/pod/threads::shared
Perl 執行緒寄件清單:https://lists.perl.org/list/ithreads.html
以下是 Jürgen Christoffel 提供的簡短參考文獻
Birrell, Andrew D. 使用執行緒進行程式設計簡介。Digital Equipment Corporation,1989 年,DEC-SRC 研究報告 #35,線上可取得 https://www.hpl.hp.com/techreports/Compaq-DEC/SRC-RR-35.pdf(強烈推薦)
Robbins, Kay. A.,以及 Steven Robbins。實用 Unix 程式設計:並行、通訊和多執行緒指南。Prentice-Hall,1996 年。
Lewis, Bill,以及 Daniel J. Berg。使用 Pthreads 進行多執行緒程式設計。Prentice Hall,1997 年,ISBN 0-13-443698-9(撰寫良好的執行緒簡介)
Nelson, Greg(編輯)。使用 Modula-3 進行系統程式設計。Prentice Hall,1991 年,ISBN 0-13-590464-1。
Nichols, Bradford、Dick Buttlar 和 Jacqueline Proulx Farrell。Pthreads 程式設計。O'Reilly & Associates,1996 年,ISBN 156592-115-1(涵蓋 POSIX 執行緒)
Boykin, Joseph、David Kirschen、Alan Langerman 和 Susan LoVerso。在 Mach 下進行程式設計。Addison-Wesley,1994 年,ISBN 0-201-52739-1。
Tanenbaum, Andrew S. 分散式作業系統。Prentice Hall,1995 年,ISBN 0-13-219908-4(絕佳教科書)
Silberschatz, Abraham,以及 Peter B. Galvin。作業系統概念,第 4 版。Addison-Wesley,1995 年,ISBN 0-201-59292-4
Arnold, Ken 和 James Gosling。Java 程式設計語言,第 2 版。Addison-Wesley,1998 年,ISBN 0-201-31006-6。
comp.programming.threads 常見問答集,http://www.serpentine.com/~bos/threads-faq/
Le Sergent, T. 和 B. Berthomieu。「在虛擬共享記憶體架構上進行增量式多執行緒垃圾回收」,收錄於記憶體管理:IWMM 92 國際研討會論文集,法國聖馬洛,1992 年 9 月,Yves Bekkers 和 Jacques Cohen 編輯。Springer,1992 年,ISBN 3540-55940-X(真實世界的執行緒應用)
Artur Bergman,「巫師不敢踏足的地方」,2002 年 6 月 11 日,http://www.perl.com/pub/a/2002/06/11/threads.html
特別感謝(不分先後)Chaim Frenkel、Steve Fink、Gurusamy Sarathy、Ilya Zakharevich、Benjamin Sugars、Jürgen Christoffel、Joshua Pritikin 和 Alan Burlison,感謝他們在現實查核和潤飾本文時提供的協助。特別感謝 Tom Christiansen 重新撰寫質數產生器。
丹·蘇加斯基 <dan@sidhe.org>
亞瑟·伯格曼稍作修改以符合新的執行緒模型/模組。
喬格·沃爾特 <jwalt@cpan.org> 稍作修改,以更簡潔地說明 Perl 程式碼的執行緒安全性。
伊莉莎白·馬提森 <liz@dijkmat.nl> 稍作重新排列,以減少對 yield() 的強調。
本文的原始版本最初出現在 Perl Journal #10 中,版權為 1998 年 Perl Journal 所有。它由強·歐文特和 Perl Journal 提供。本文件可以在與 Perl 相同的條款下分發。