多线程的Python 教程“贪吃蛇”

  • A+
所属分类:Python

  本指南的里代码可以在这里下载:  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

  • 我的微信
  • 这是我的微信扫一扫
  • weinxin
  • 我的微信公众号
  • 我的微信公众号扫一扫
  • weinxin