多執行緒的Python 教程--“貪吃蛇”

oschina發表於2013-05-02

  本指南的裡程式碼可以在這裡下載:  threadworms.py ,或者從  GitHub。程式碼需要  Python 3 或 Python 2 ,同時也需要安裝  Pygame 。

多執行緒的Python 教程--“貪吃蛇”

點選檢視大版本圖片

  這是一篇為初學者準備的關於  執行緒 和Python中的多執行緒程式設計的指南。 如果你有一些  類(class)的基礎知識 (什麼是類,如何定義方法(method),還有方法總是將self作為他的第一個引數,子類是什麼以及子類如何從父類繼承一個方法,等等)這篇指南會對你有所幫助。  這裡有一篇較為深入地介紹類(class)的指南。

  我們用到的例子是  “貪吃蛇” 的克隆,它有很多條在一個格子狀的區域前行的蠕蟲,每一條蟲子在一個單獨的執行緒裡執行。

  如果你知道執行緒相關的知識,那就跳過這一章節,看看執行緒在Python中如何使用。

  當你執行一個普通的Python程式時,這個程式從第一行開始,一行接一行的的執行。迴圈和函式可能讓程式上下跳轉,但是給定一行程式碼的位置,你可以輕易地找到下一行從哪裡執行。你可以把一根手指指到你的.py檔案中,一行一行的追蹤你的程式執行到哪裡了。這就是單執行緒程式設計(single-threaded programming)。

  然而,使用多個執行緒就像將第二跟手指放到螢幕上。每個手指還是像之前那樣移動,但是它們現在是同時在移動。

  但是事實上,它們並不是同時的。你的手指在交替著移動。擁有多核心(multicore)的處理器可以真正意義上的同時執行兩條指令,但是Python程式有一個叫做  GIL (全域性直譯器鎖 global interpreter lock) 東西,它會限制Python程式單核執行。

 執行緒是什麼?為什麼執行緒很有用?

  Python的直譯器會一會兒執行一個執行緒,一會兒執行另一個執行緒。但是這切換的速度如此之快,快的讓你根本無法察覺,以至於這些執行緒看起來像是同時執行。

  你可以在你的Python程式中開幾十或者幾百個執行緒(那真是好多的手指)。這並不能讓你的程式快上幾十上百倍(事實上這些執行緒還是在使用同一個CPU),但是它能讓你的程式更強大,更高效。

  舉個例子,你要寫個函式,這個函式會下載一個內容全是名字的檔案,然後將這個檔案的內容排序,然後將排序好的內容存為另一個檔案。如果這裡有上百個這樣的檔案,那麼你可能會在一個迴圈中呼叫這個函式來處理每個檔案:下載,排序,儲存,下載,排序,儲存,下載,排序,儲存...

  這三個步驟用到了你電腦上的不同資源:下載用到了網路,排序用到了CPU,儲存檔案用到了硬碟。同時,這三個操作都可能被延緩。例如,你下在檔案的伺服器可能很慢,或者你的頻寬很小。

  這種情況先,使用多個執行緒,每個執行緒處理一個檔案是一個比較明智的選擇。這不僅能更好的利用你的頻寬,而且當你的CPU工作的時候,網路也在工作。這將更有效的利用你的電腦。

 是什麼讓多執行緒程式設計那麼棘手?

  當然,在上面的例子,每個執行緒只做它自己獨立的事情也不需要去和其他執行緒通訊或同步任何東西。你可以只編寫簡單的下載-排序-寫入程式的單執行緒版本同時獨立地執行程式上百遍。(儘管它可能在每次打字和點選來執行每個程式來下載不同檔案的時候有點痛苦。)

  大多數多執行緒程式共享訪問相同的變數,但這就是棘手的東西。

多執行緒的Python 教程--“貪吃蛇”

(來自  Brad Montgomery的圖片)

  這裡是一個常用的比喻:告訴你有兩個售票機器人。它們的任務很簡單:

  1. 詢問消費者要哪個位置。
  2. 檢查列表看下座位是不是可以用。
  3. 獲取該座位的票。
  4. 從列表上移出改座位。

  一個顧客問機器A要42號座位的票。機器A從列表中檢查和發現座位可以用,因此它獲取到那張票。但在機器A能從列表中刪除改座位的前,機器B被不同顧客詢問42號座位。機器B檢查列表也看到了座位仍然可以用,所以它嘗試獲取到這個座位的票。但是機器B不能找到42號座位的票。這計算不了了,同時機器B的電子大腦也爆炸了。機器A在後來把42號座位的票從列表上刪除了。

  上面的問題會發生是因為機關兩個機器人(或者說,兩個執行緒)都在獨立執行,他們兩者都在讀寫一個共享的列表(或者說,一個變數)。你的程式可能很難去修復這種很難重現的bug,因為Python的執行緒執行切換具有 非確定性,那就是,程式每次執行都在做不同的東西。我們不習慣變數裡的資料“魔術地”從一行轉到下一個僅僅是因為執行緒在他們之間執行。

  當從一個執行緒的執行切換到另外一個執行緒,這就是上下文切換。

  這也存在死鎖的問題,通常用 哲學家就餐問題的比喻來解釋。五個哲學家圍坐一個桌子吃義大利麵條,但需要兩個叉子。在每個哲學家之間有一個叉子(總共有5個)。哲學家用這個方法來吃麵條:

  1. 理性地思考一會兒。
  2. 拿起你左邊的叉子。
  3. 等待你右邊的叉子能用。
  4. 拿起右邊的叉子。
  5. 吃麵條。
  6. 放下叉子。
  7. 跳到步驟1。

多執行緒的Python 教程--“貪吃蛇”

  從實際上他們會和旁邊的人共享叉子(我不喜歡),這方法看起來似乎能有效。但馬上或者稍後桌子上每個人最後都會拿著左邊的叉子在手擋同時等待右邊的叉子。但因為每個人都拿著他們旁邊的人等待的叉子同時也不會在他們吃之前放下他們,這些哲學家就在一個死鎖狀態。他們會拿著左邊的叉子在手上又永遠不會拿到右邊的叉子,所以他們永遠不會吃到麵條也使用者不會放下他們左手上的叉子。哲學家都要餓死了(除了伏爾泰,它實際上是個機器人。沒有義大利麵條,他的電子大腦會爆炸)

  還存在一種被稱為 活鎖的情況。當這種情況發生時,所有的執行緒都讓出資源,導致任務不能繼續進行下去。就像在大廳裡迎面走近的兩個人,他們都站到一邊,等待對方先過去,結果兩個人都卡住了。然後他們又同時試圖走到對面,又互相阻礙了對方。他們持續地這樣讓開-走近,直到他們都筋疲力盡。 

  在多執行緒程式設計中,還可能存在其他一些問題,比如飢餓(不是真的肚子餓的問題,只是大家都這麼叫它)。這些問題在電腦科學中普遍歸類於"  並行性"這個範疇。不過在此我們只會處理一個簡化的例項。 

  鎖

  在多執行緒程式設計中,一個防範bug的辦法是使用鎖。在一個執行緒讀取或者修改該某個共享變數前,它先試圖獲得一個鎖。如果獲得了這個鎖,這個執行緒將繼續對這個共享變數進行讀寫。反之,它將一直等待直到這個鎖再次可用。

  一旦完成對共享變數的操作,執行緒就會“釋放”這個鎖。這樣其他等待這個鎖的執行緒就能獲取它了。

  回到售票機器人的比喻。一個機器人把座位列表拿起來 (這個列表就是鎖),檢查後發現客戶要求的座位還在,於是把相應的票取出來,把這個座位從列表中刪去。最後機器人把列表放回去的動作,就相當於“釋放了這個鎖“。如果另一個機器人需要檢視座位列表但列表不在,它會一直等待直到座位列表再次可用。

  寫程式碼時,如果忘記對鎖進行釋放,就可能引入bug。這將導致死鎖情況的發生,因為另外一個等待該鎖釋放的執行緒會一直掛在那裡無事可做。 

  Python中的執行緒

  OK,現在讓我們來寫一段python程式來說明如何使用執行緒和鎖。這段程式基於一個貪吃蛇遊戲,是我在拙著《 Making Games with Python & Pygame第六章中克隆的一個版本。這條蛇只會在螢幕上跑來跑去,不會吃蘋果。另外,程式中有不止一條蛇。每一條蛇由不同的執行緒控制。共享變數中的資料結構記錄了螢幕上哪個位置(在這個程式中被成為"格子")被一條蛇佔據.如果一條蛇已經在某個格子裡了,則其他蛇不能前進到此處並佔據這個格子。我們將使用鎖來保證兩條蛇不會佔據同一個格子。

  這篇教程的程式碼可以從此處下載: threadworms.py 或者 GitHub。這份程式碼相容 Python2和Python3, 另外執行該程式碼需要安裝 Pygame.

  這裡是上述在我們的threadworms.py程式裡執行緒相關的程式碼:

import threading

  Python的執行緒庫名為threading的模組,所以首先要匯入這個模組。

GRID_LOCK = threading.Lock()

  在threading模組裡的Lock類有acquire()和release()方法。我們會新建一個Lock物件和把它存放在名為GRID_LOCK的全域性變數裡。(因為類似網格的螢幕和被單元佔據的狀態值會儲存在名為GRID的全域性變數裡。這兩種方式有點意外。)

# A global variable that the Worm threads check to see if they should exit.
WORMS_RUNNING = True

  我沒的WORMS_RUNNING全域性變數通常由worm執行緒來檢查是否應該退出。呼叫sys.exit()不會停止程式,因為它只是退出呼叫它的執行緒。只要有其他執行緒仍然在執行程式還會繼續。在我們程式裡的主執行緒(負責Pygame渲染和時間處理)會在它呼叫pygame.quit()和sys.exit()前設定WORMS_RUNNING為False,直到實際最後的執行緒退出然後程式終止,它就會退出。

class Worm(threading.Thread):
    def __ init__(self, name='Worm', maxsize=None, color=None, speed=None):
        threading.Thread.__init__(self)
        self.name = name

  執行緒程式碼必需充Thread類的子類開始(在threading模組裡的)。我們的執行緒子類會名為Worm,因為它負責控制蟲子。但由於我們的Worm類會先使用我們需要呼叫threading.Thread的___init__()方法,所以你不需要一個__init()__函式。同樣也可以選擇過載該命名方法。我們的__init__()函式使用字串"Worm'作為預設,但我們能夠提供每個執行緒一個獨立的名字。Python會線上程崩潰的時候在錯誤資訊裡顯示執行緒的名字。 

GRID_LOCK.acquire()
# ...some code that reads or modifies GRID...
GRID_LOCK.release()

  在我們讀獲取修改GRID變數裡的值前,執行緒程式碼應該去嘗試申請鎖。如果鎖不可用,方法對acquire()呼叫不會返回同時直到鎖能用前都會“阻塞”。執行緒在這種情況下會展廳。這樣,我們就知道在acquire()後的程式碼的呼叫只會線上程申請到鎖後才發生。

  在一段程式碼中獲取和釋放一個鎖,可以保證當前執行緒在執行這段程式碼的時候,其他執行緒不會執行這段程式碼。這會讓這段程式碼變成“原子的”,因為這段程式碼總會被當成一個整體。

  在對GRID變數的操作完成之後,使用release方法釋放這個鎖:

def run(self):
	# 這裡是執行緒程式碼

  當Worm類(是一個threading.Thread的字類)的start()方法被呼叫時,一個執行緒開始執行。我們不需要自己實現start()方法,因為它是從threading.Thread中繼承的。 當呼叫start()時,會建立一個新的執行緒,這個新的執行緒會執行run()方法中的程式碼。不要直接呼叫run()方法,這樣不會建立一個新的執行緒。

  需要明白的東西:使用start()新開一個執行緒,但是這個執行緒會執行run()裡面的程式碼。我們不需要自己實現start(),因為它是從 threading.Thread中繼承的。既然執行緒要執行run()中的程式碼,我們要自己實現run()。

  當run()的呼叫結束時, (或者sys.exit()這個函式在這個執行緒中被呼叫),這個執行緒會被銷燬。在一個程式結束之前,它裡面所有的執行緒需要被銷燬。只要這個程式中有一個還在執行的執行緒,這個程式就不會結束。

  所以,當start()被呼叫時,就是你將一根新的手指放到run()中開始追蹤程式碼的時候。你的第一根手指在執行完start()後會繼續追蹤程式碼。

 一個簡單的多執行緒例子

  在我們進入Threadworm程式碼前,先看下一個廢棄了的簡單多執行緒程式:

import threading
TOTAL = 0
class CountThread(threading.Thread):
    def run(self):
        global TOTAL
        for i in range(100):
            TOTAL = TOTAL + 1
        print('%s\n' % (TOTAL))
a = CountThread()
b = CountThread()
a.start()
b.start()

  這個程式定義一個叫做CountThread的新類。當一個CountThread物件的start()方法被呼叫,一個會迴圈100次同時在每次迴圈迭代中為TOTAL全域性變數的值加1的新的執行緒會被建立(在變數之間共享的)。

  一旦我們建立兩個CountThread物件,無論哪個完成了都會顯示200.每個執行緒為TOTAL增加100,並且有兩個執行緒。當我們執行這個程式的時候,我們會看到:

100
200

  因為第一個數字是100,我們能說出一個執行緒在上下文切換髮生前在整個迴圈裡發生了什麼。

  然而,如果我們把範圍從100改到100000,我可能期待第二個數字是200000,一旦每個執行緒為TOTAL增加100000同時有兩個執行緒。但當我們執行程式的時候,一些像這樣的東西會出現(你的數字可能有點不同):

143294
149129

  第二個數字不是200000!它比實際數字要小。這樣會發生的原因是因為我們沒有在程式碼讀寫TOTAL變數周圍使用鎖,而它是在多執行緒中共享的。

  看這行:

TOTAL = TOTAL + 1

  如果TOTAL設定為99,然後你會認為TOTAL+1等於99+1然後就是100了,然後100作為新的值存入到TOTAL裡。然後在下一次迭代,TOTAL+1會是100+1或101,它都會作為新的值存到TOTAL裡。

  但在說TOTAL+1等於99+1時,執行切換到另外一個執行緒,它也是執行TOTAL=TOTAL+1那一行。在TOTAL裡的值還是99,所以TOTAL+1在這第二個執行緒會等價於99+1。

  然後,另外一個上下文切換髮生的時候又回到第一個執行緒TOTAL=99+1的執行到中間的地方。整數100會分配到TOTAL。現在執行又切換到第二個執行緒。

  在第二個執行緒,TOTAL=99+1大概要執行了。即使現在TOTAL是100,在第二個執行緒裡的TOTAL+1這裡已經等於是99+1了。所以第二個執行緒最終也把整數100分配到TOTAL。即使TOTAL=TOTAL+1被執行了兩次(每次又一個執行緒執行),但TOTAL的真實值只是加了1!

  這問題是由於程式碼的TOTAL=TOTAL+1這行不是原子的。上下文切換切換可以剛好在這行執行的中間發生。我們需要在程式碼周圍加上鎖讓它成為一個原子操作。

  新的程式碼修復了這個問題:

import threading
TOTAL = 0
MY_LOCK = threading.Lock()
class CountThread(threading.Thread):
    def run(self):
        global TOTAL
        for i in range(100000):
            MY_LOCK.acquire()
            TOTAL = TOTAL + 1
            MY_LOCK.release()
        print('%s\n' % (TOTAL))
a = CountThread()
b = CountThread()
a.start()
b.start()

  當我們執行這段程式碼時候,下面就是輸出結果了(你的第一個數字可能有一點不同):

199083
200000

  第二個數字是20000告訴了我們TOTAL=TOTAL+1這行正確在它執行的200,000次裡執行。

 解釋Threadworms程式

  我打算用程式的 threadworms_nocomments.py版本,因為它沒有冗長的註釋在裡面。在每行的開頭都包含了行號(它們不是實際Python原始碼的一部分)。我跳過很多註釋部分因為它們都是一些自我解釋。在下面程式碼不需要你真正瞭解Pygame。Pygame只是負責建立視窗和畫線和方塊。

  一件要了解的事情是Pygame用一個三個整數的元組來代表顏色。每個這些整數的範圍是從0到255並且代表了RGB(紅-綠-藍)顏色的值。所以(0,0,0)是黑和(255,255,255)是白,同時(255,0,0)是紅和(255,0,255)是紫等。

  9. import random, pygame, sys, threading
 10. from pygame.locals import *
 11.
 12. # Setting up constants
 13. NUM_WORMS = 24  # the number of worms in the grid
 14. FPS = 30        # frames per second that the program runs
 15. CELL_SIZE = 20  # how many pixels wide and high each "cell" in the grid is
 16. CELLS_WIDE = 32 # how many cells wide the grid is
 17. CELLS_HIGH = 24 # how many cells high the grid is

  程式碼的最上部分匯入一些我們程式需要的模組同時定義一些常量值。會很容易地修改這些常量。增加或減少FPS的值不會影響到蟲子跑得有多快,它只是改變螢幕重新整理頻率。如果你把這個值設定得很低,它看起來這些蟲子都會瞬間移動,因為它們在每次螢幕重新整理的時候移動了多個位置。

  CELL_SIZE代表螢幕上的網格的每個方塊有多大(畫素)。如果你想改變單元的數量,就修改CELLS_WIDE和CELLS_HIGH常量。

 20. GRID = []
 21. for x in range(CELLS_WIDE):
 22.     GRID.append([None] * CELLS_HIGH)

  全域性變數GRID會包含網格狀態跟蹤資料。它是一些簡單的列表所以GRID[x][y]會指向X和Y座標的單元。(在程式設計裡,(0,0)遠點在螢幕左上方。X增加會往右移動(就像在數學課裡)但Y增加會往下移動。)

  如果GRID[x][y]設定為None,然後單元就不會被佔據。否則的話,GRID[x][y]會設定為一個RGB三元組。(這個資訊對在螢幕上畫網格的時候有用。)

 24. GRID_LOCK = threading.Lock() # pun was not intended

  第24行建立一個Lock物件,我們的執行緒程式碼會在讀或修改GRID的時候申請和釋放這個鎖。

 26. # Constants for some colors.
 27. #             R    G    B
 28. WHITE     = (255, 255, 255)
 29. BLACK     = (  0,   0,   0)
 30. DARKGRAY  = ( 40,  40,  40)
 31. BGCOLOR = BLACK             # color to use for the background of the grid
 32. GRID_LINES_COLOR = DARKGRAY # color to use for the lines of the grid
RGB tuples are kind of hard to read, so I usually set up some constants for them.
 33.
 34. # Calculate total pixels wide and high that the full window is
 35. WINDOWWIDTH = CELL_SIZE * CELLS_WIDE
 36. WINDOWHEIGHT = CELL_SIZE * CELLS_HIGH
 37.
 38. UP = 'up'
 39. DOWN = 'down'
 40. LEFT = 'left'
 41. RIGHT = 'right'

  還有更多的簡單常量。我使用如DOWN和RIGHT這些常量代替字串“down”和“right”因為如果我錯誤使用使用常量(如DOWN)然後Python會直接由於一個NameError異常而崩潰。這比我用錯誤的值如“down”更好,它不會直接程式崩潰但以後會導致一些bug,讓它更難去跟蹤。

 43. HEAD = 0
 44. BUTT = -1 # negative indexes count from the end, so -1 will always be the last index

  每個蟲子會用一個像{'x':42,'y':7}的字典列表來表示。每一個字典都代表了蟲子身體的一部分。列表前面的字典(索引為0)是頭部,而在最後的字典(索引-1,使用Python的負數索引來從後面開始計數)是蟲子的屁股。

  (在電腦科學裡,“頭”通常指向列表或佇列的第一個元素,而“尾”指向任何一個在頭後面的元素。所以我使用“屁股”來指向最後一個元素。我同樣有點笨。)

多執行緒的Python 教程--“貪吃蛇”

  上面的蟲子能用這樣的列表能表示:[{'x': 7, 'y': 2}, {'x': 7, 'y': 3}, {'x': 7, 'y': 4}, {'x': 8, 'y': 4}, {'x': 9, 'y': 4}, {'x': 10, 'y': 4}, {'x': 11, 'y': 4}, {'x': 11, 'y': 3}, {'x': 11, 'y': 2}]

 46. # A global variable that the Worm threads check to see if they should exit.
 47. WORMS_RUNNING = True

  隨著一個執行緒在執行,程式會繼續地執行。負責渲染螢幕的主執行緒會檢查使用者什麼時候點選了視窗上的關閉按鈕或者按ESC鍵,所以它需要一個方法來告訴蟲子執行緒退出。我們會編寫蟲子執行緒程式碼來定期檢查WORMS_RUNNING。如果WORMS_RUNNING是False的,執行緒就會終止自己。

 49. class Worm(threading.Thread): # "Thread" is a class in the "threading" module.
 50.     def __init__(self, name='Worm', maxsize=None, color=None, speed=None):

  這裡是我們的Worm類,它是threading.Thread的子類。每個蟲子都有一個名字(在蟲子碰撞的時候會顯示,幫助我們分辨出是那個執行緒崩潰),一個大小,顏色和速度。都提供了預設值,但如果喜歡我們能夠指定這些屬性的值。

 56.         threading.Thread.__init__(self) # since we are overriding the Thread class, we need to first call its __init__() method.

  一旦我們過載了__init__()方法,我們需要呼叫父類的__init__()方法以便於它能夠初始化所有執行緒內容。(我們不需要知道它怎麼工作,只要記住呼叫它。)

 57.
 58.         self.name = name
 59.
 60.         # Set the maxsize to the parameter, or to a random maxsize.
 61.         if maxsize is None:
 62.             self.maxsize = random.randint(4, 10)
 63.
 64.             # Have a small chance of a super long worm.
 65.             if random.randint(0,4) == 0:
 66.                 self.maxsize += random.randint(10, 20)
 67.         else:
 68.             self.maxsize = maxsize
 69.
 70.         # Set the color to the parameter, or to a random color.
 71.         if color is None:
 72.             self.color = (random.randint(60, 255), random.randint(60, 255), random.randint(60, 255))
 73.         else:
 74.             self.color = color
 75.
 76.         # Set the speed to the parameter, or to a random number.
 77.         if speed is None:
 78.             self.speed = random.randint(20, 500) # wait time before movements will be between 0.02 and 0.5 seconds
 79.         else:
 80.             self.speed = speed

  上面的程式碼設定一個有隨機大小、顏色和速度的蟲子,除非引數指定了值,它都用預設的值。

 82.         GRID_LOCK.acquire() # block until this thread can acquire the lock
 83.
 84.         while True:
 85.             startx = random.randint(0, CELLS_WIDE - 1)
 86.             starty = random.randint(0, CELLS_HIGH - 1)
 87.             if GRID[startx][starty] is None:
 88.                 break # we've found an unoccupied cell in the grid
 89.
 90.         GRID[startx][starty] = self.color # modify the shared data structure
 91.
 92.         GRID_LOCK.release()

  我們需要決定一個蟲子的隨機開始的位置。要讓這個簡單點,所有蟲子都從一個身體部分來開始同時增長直到他們到達最大值。但我們需要確保網格上的隨機位置還沒被佔據。這取決對GRID全域性變數的讀和修改,所以我們需要在做之前申請和釋放GRID_LOCK鎖。

  (備註,你可能會好奇為什麼我們不用把“global GRID”這行放在方法的開始。GRID是一個全域性變數同時我們要在這個方法裡修改它,並且沒有一個Python要去考慮的全域性的語句,只有在有一個區域性變數和GRID全域性變數同名的時候,它才需要。但是,如果你看仔細點,我們只是改變GRIDlist列表裡的值,但永遠不是GRID本身的值。那就是說,我們像這樣“GRID[startx][starty]=self.color”的程式碼永遠不會“GRID=someValue”。因為我們不會實際改變GRID本身,Python會考慮在方法裡用GRID的名字來指向全域性變數GRID。)

  我們繼續迴圈知道我們找到一個沒被佔據的單元,然後標記這個單元現在被佔據了。之後,我們完成讀和修改GRID,所以就釋放GRID_LOCK鎖。

  (另外一個備註,如果網格里沒有可用的單元,這個迴圈會一直繼續下去同時執行緒會“hang(掛死)”。因為其他執行緒會繼續執行,你可能不會注意到這個問題。新的蟲子不會建立但是程式剩下的蟲子還會繼續正常執行。然而,當你嘗試退出的時候,因為掛死的執行緒永遠不會去檢查WORMS_RUNNING來知道自己應該退出同時程式會拒絕終止。你就得通過作業系統來強制關閉程式。只要確定在沒更多空間時不建立更多蟲子。)

 96.         self.body = [{'x': startx, 'y': starty}]
 97.         self.direction = random.choice((UP, DOWN, LEFT, RIGHT))

  開始的身體部分會加入到body成員變數。body成員變數是一個所有身體部分的位置的列表。蟲子頭部的方向存放在direction成員變數裡。

  技術上來說,因為現在蟲子只有身體的一部分,它的列表的第一項既是最後一項,蟲子的頭和屁股是一樣的。

100.     def run(self):
101.         while True:
102.             if not WORMS_RUNNING:
103.                 return # A thread terminates when run() returns.

  run()方法在蟲子的start()方法被呼叫的時候就會被呼叫。在run()裡的程式碼在一個全新的執行緒裡執行。我們會用一個無限迴圈來讓蟲子持續在網格上移動。在每個迴圈迭代上我們要做的第一件事是檢查是否WORMS_RUNNING被設定為False,如果是這樣的話,我們應該從這個方法返回。

  如果我們從執行緒裡呼叫sys.exit()或則當run()方法返回,執行緒就會終止自己。

105.             # Randomly decide to change direction
106.             if random.randint(0, 100) < 20: # 20% to change direction
107.                 self.direction = random.choice((UP, DOWN, LEFT, RIGHT))

  在移動的每一步上,有20%的機會蟲子會隨機地改變方向。(儘管新的方向和當前方向相同。但我快速地編寫這段程式碼。)

109.             GRID_LOCK.acquire() # don't return (that is, block) until this thread can acquire the lock
110.
111.             nextx, nexty = self.getNextPosition()
112.             if nextx in (-1, CELLS_WIDE) or nexty in (-1, CELLS_HIGH) or GRID[nextx][nexty] is not None:
113.                 # The space the worm is heading towards is taken, so find a new direction.
114.                 self.direction = self.getNewDirection()
115.
116.                 if self.direction is None:
117.                     # No places to move, so try reversing our worm.
118.                     self.body.reverse() # Now the head is the butt and the butt is the head. Magic!
119.                     self.direction = self.getNewDirection()
120.
121.                 if self.direction is not None:
122.                     # It is possible to move in some direction, so reask for the next postion.
123.                     nextx, nexty = self.getNextPosition()
124.
125.             if self.direction is not None:
126.                 # Space on the grid is free, so move there.
127.                 GRID[nextx][nexty] = self.color # update the GRID state
128.                 self.body.insert(0, {'x': nextx, 'y': nexty}) # update this worm's own state
129.
130.                 # Check if we've grown too long, and cut off tail if we have.
131.                 # This gives the illusion of the worm moving.
132.                 if len(self.body) > self.maxsize:
133.                     GRID[self.body[BUTT]['x']][self.body[BUTT]['y']] = None # update the GRID state
134.                     del self.body[BUTT] # update this worm's own state (heh heh, worm butt)
135.             else:
136.                 self.direction = random.choice((UP, DOWN, LEFT, RIGHT)) # can't move, so just do nothing for now but set a new random direction
137.
138.             GRID_LOCK.release()

  上面的程式碼處理蟲子移動一個位置。一旦這涉及到讀和修改GRID,我們需要申請GRID_LOCK的鎖。本來,蟲子會嘗試移動到一個它的方向成員變數描述的方向上的一個位置。如果這個單元是網格的便捷或者已經被佔據,然後蟲子會改變它的方向。如果蟲子在各個方向都被阻擋了,它就會反轉自己來讓屁股成為頭和頭又變為屁股。如果蟲子還是不能在任何方向上移動,然後它會停止原地不動。 

140.            pygame.time.wait(self.speed)

  在蟲子移動一個位置之後(或至少去嘗試),我們會讓執行緒睡眠。Pygame有個叫做wait()的函式做的和time.sleep()一樣的東西,除了wait()的引數用毫秒的整數來代替秒。

  Pygame的pygame.time.wait()和Python標準庫的time.time()函式(以及pygame的tick()方法)都足夠聰明地去告訴作業系統讓該執行緒睡眠一段時間同時去執行其他替代的執行緒。當然,當作業系統能夠在任何使用中斷我們的執行緒來處理另外的執行緒,呼叫wait()或sleep()是顯式地說,“去吧,不要執行這個執行緒X毫秒。”

  這樣編寫“wait”程式碼不能出現:

startOfWait = time.time()
while time.time() - 5 > startOfWait:
    pass # do nothing for 5 seconds

  上面的程式碼也實現了“在等待”,但是對於作業系統來說,你的執行緒看起來還在執行程式碼(幾時你的程式碼沒有做什麼只是在迴圈等待5秒過去)。這不是有效的因為時間耗費在執行上面的無意義的迴圈上,這些時間其實可以花在其他執行緒上的。

  當然,如果所有的蟲子的執行緒都在睡眠,計算機會知道它能夠使用CPU執行除我們的Python Threadworms指令碼外的其他程式。

143.     def getNextPosition(self):
144.         # Figure out the x and y of where the worm's head would be next, based
145.         # on the current position of its "head" and direction member.
146.
147.         if self.direction == UP:
148.             nextx = self.body[HEAD]['x']
149.             nexty = self.body[HEAD]['y'] - 1
150.         elif self.direction == DOWN:
151.             nextx = self.body[HEAD]['x']
152.             nexty = self.body[HEAD]['y'] + 1
153.         elif self.direction == LEFT:
154.             nextx = self.body[HEAD]['x'] - 1
155.             nexty = self.body[HEAD]['y']
156.         elif self.direction == RIGHT:
157.             nextx = self.body[HEAD]['x'] + 1
158.             nexty = self.body[HEAD]['y']
159.         else:
160.             assert False, 'Bad value for self.direction: %s' % self.direction
161.
162.         return nextx, nexty

  getNextPosition()指出了蟲子下次會去哪裡,接著給出它的頭的位置和它要走向的方向。

165.     def getNewDirection(self):
166.         x = self.body[HEAD]['x'] # syntactic sugar, makes the code below more readable
167.         y = self.body[HEAD]['y']
168.
169.         # Compile a list of possible directions the worm can move.
170.         newDirection = []
171.         if y - 1 not in (-1, CELLS_HIGH) and GRID[x][y - 1] is None:
172.             newDirection.append(UP)
173.         if y + 1 not in (-1, CELLS_HIGH) and GRID[x][y + 1] is None:
174.             newDirection.append(DOWN)
175.         if x - 1 not in (-1, CELLS_WIDE) and GRID[x - 1][y] is None:
176.             newDirection.append(LEFT)
177.         if x + 1 not in (-1, CELLS_WIDE) and GRID[x + 1][y] is None:
178.             newDirection.append(RIGHT)
179.
180.         if newDirection == []:
181.             return None # None is returned when there are no possible ways for the worm to move.
182.
183.         return random.choice(newDirection)

  getNewDirection()方法返回一個方向(UP,DOWN,LEFT或RIGHT的字串中一個)表示在網格里還沒被佔據的一個單元。如果沒有允許的單元讓(蟲子)的頭移過去,方法就會返回None。

185. def main():
186.     global FPSCLOCK, DISPLAYSURF
187.
188.     # Draw some walls on the grid
189.     squares = """
190. ...........................
191. ...........................
192. ...........................
193. .H..H..EEE..L....L.....OO..
194. .H..H..E....L....L....O..O.
195. .HHHH..EE...L....L....O..O.
196. .H..H..E....L....L....O..O.
197. .H..H..EEE..LLL..LLL...OO..
198. ...........................
199. .W.....W...OO...RRR..MM.MM.
200. .W.....W..O..O..R.R..M.M.M.
201. .W..W..W..O..O..RR...M.M.M.
202. .W..W..W..O..O..R.R..M...M.
203. ..WW.WW....OO...R.R..M...M.
204. ...........................
205. ...........................
206. """
207.     #setGridSquares(squares)

  setGridSquares()函式能夠用在網格上通過傳入一個多行的字串來畫靜態區域。前一個字元程式碼沒有改變,一個空的字元意味著“設定它為沒有被佔據”然後其他字元會代表一個放置在網格上的靜態區域。如果你想看到“Hello worm”文字在區域裡寫出來,你可以取消207行的備註。

209.     # Pygame window set up.
210.     pygame.init()
211.     FPSCLOCK = pygame.time.Clock()
212.     DISPLAYSURF = pygame.display.set_mode((WINDOWWIDTH, WINDOWHEIGHT))
213.     pygame.display.set_caption('Threadworms')

  這是一個Pygame標準的設定,用來為我們的程式建立一個視窗。

215.     # Create the worm objects.
216.     worms = [] # a list that contains all the worm objects
217.     for i in range(NUM_WORMS):
218.         worms.append(Worm())
219.         worms[-1].start() # Start the worm code in its own thread.

  這段程式碼建立Worm物件然後通過呼叫start()方法建立它們的執行緒。在每個蟲子的run()方法裡的程式碼會在這時候開始執行在一個隔離的執行緒裡。

221.     while True: # main game loop
222.         handleEvents()
223.         drawGrid()
224.
225.         pygame.display.update()
226.         FPSCLOCK.tick(FPS)

  主要的遊戲迴圈十分簡單。handleEvents()函式會檢查使用者是否終止了程式和drawGrid()函式會畫出網格線和單元到螢幕上。pygame.display.update()函式告訴視窗更新螢幕,在這之後tick()方法會暫停一定時間以達到在FPS裡指定的幀率。

229. def handleEvents():
230.     # The only event we need to handle in this program is when it terminates.
231.     global WORMS_RUNNING
232.
233.     for event in pygame.event.get(): # event handling loop
234.         if (event.type == QUIT) or (event.type == KEYDOWN and event.key == K_ESCAPE):
235.             WORMS_RUNNING = False # Setting this to False tells the Worm threads to exit.
236.             pygame.quit()
237.             sys.exit()

  Pygame事件嫩告訴我們時候時候使用者按下了ESC鍵或者點選關閉按鈕來關閉視窗。在這種情況下,我們想設定WORMS_RUNNING為False以便於執行緒會終止自己然後主執行緒關閉Pygame和退出。

240. def drawGrid():
241.     # Draw the grid lines.
242.     DISPLAYSURF.fill(BGCOLOR)
243.     for x in range(0, WINDOWWIDTH, CELL_SIZE): # draw vertical lines
244.         pygame.draw.line(DISPLAYSURF, GRID_LINES_COLOR, (x, 0), (x, WINDOWHEIGHT))
245.     for y in range(0, WINDOWHEIGHT, CELL_SIZE): # draw horizontal lines
246.         pygame.draw.line(DISPLAYSURF, GRID_LINES_COLOR, (0, y), (WINDOWWIDTH, y))

  這段程式碼根據GRID裡的值來畫出螢幕。但首先它會畫出網格線。

248.     # The main thread that stays in the main loop (which calls drawGrid) also
249.     # needs to acquire the GRID_LOCK lock before modifying the GRID variable.
250.     GRID_LOCK.acquire()
251.
252.     for x in range(0, CELLS_WIDE):
253.         for y in range(0, CELLS_HIGH):
254.             if GRID[x][y] is None:
255.                 continue # No body segment at this cell to draw, so skip it
256.
257.             color = GRID[x][y] # modify the GRID data structure
258.
259.             # Draw the body segment on the screen
260.             darkerColor = (max(color[0] - 50, 0), max(color[1] - 50, 0), max(color[2] - 50, 0))
261.             pygame.draw.rect(DISPLAYSURF, darkerColor, (x * CELL_SIZE,     y * CELL_SIZE,     CELL_SIZE,     CELL_SIZE    ))
262.             pygame.draw.rect(DISPLAYSURF, color,       (x * CELL_SIZE + 4, y * CELL_SIZE + 4, CELL_SIZE - 8, CELL_SIZE - 8))
263.
264.     GRID_LOCK.release() # We're done messing with GRID, so release the lock.

  因為程式碼讀GRID變數,我會首先申請GRID_LOCK鎖。如果一個單元被佔據了(那就是,它會設定一個RGB元組數值到GRID變數),程式碼會在該單元內上色。

267. def setGridSquares(squares, color=(192, 192, 192)):
268.     # squares is set to a value like:
269.     # """
270.     # ......
271.     # ...XX.
272.     # ...XX.
273.     # ......
274.     # """
275.
276.     squares = squares.split('\n')
277.     if squares[0] == '':
278.         del squares[0]
279.     if squares[-1] == '':
280.         del squares[-1]
281.
282.     GRID_LOCK.acquire()
283.     for y in range(min(len(squares), CELLS_HIGH)):
284.         for x in range(min(len(squares[y]), CELLS_WIDE)):
285.             if squares[y][x] == ' ':
286.                 GRID[x][y] = None
287.             elif squares[y][x] == '.':
288.                 pass
289.             else:
290.                 GRID[x][y] = color
291.     GRID_LOCK.release()

  setGridSquares()上面解釋過了,它能夠寫靜態的塊到網格上。

294. if __name__ == '__main__':
295.     main()

  上面是Python的一個技巧。作為把mian程式碼放到全域性域的代替,我們把它放到一個名為main()的函式裡,它在底部被呼叫。這保證了所有函式在main()執行的程式碼前已定義好。__name__變數只有在自己執行自己的時候才設定為字串"__main__",和從其他程式作為一個模組匯入相反。

 總結

  它就是這樣了!多執行緒程式是相當簡單地去解釋,但它很難去明白怎樣來讓你自己的多執行緒程式正確地工作。最好的學習方法是通過編寫你的程式來學習。

  實際上,我們建立起我們程式碼的方式裡,即使我們擺脫了鎖它還能完美執行。不會崩潰,儘管有時候會出現兩個蟲子進入相同單元最後都佔據了這個單元的情況。然後他們就看起來在別人身上走過。使用鎖來確定一個單元在某一時刻只能被一個蟲子佔據。

  祝你好運!

多執行緒的Python 教程--“貪吃蛇”

  英文原文:Multithreaded Python Tutorial with the “Threadworms” Demo

相關文章