草根學Python(十三)執行緒和程式

兩點水發表於2017-10-12

前言

拖了好久,不過還是得堅持。喜歡本文的話可以加下公眾號【於你供讀】。

目錄

草根學Python(十三) 執行緒和程式

執行緒與程式

執行緒與程式是作業系統裡面的術語,簡單來講,每一個應用程式都有一個自己的程式。

作業系統會為這些程式分配一些執行資源,例如記憶體空間等。在程式中,又可以建立一些執行緒,他們共享這些記憶體空間,並由作業系統呼叫,以便平行計算。

我們都知道現代作業系統比如 Mac OS X,UNIX,Linux,Windows 等可以同時執行多個任務。打個比方,你一邊在用瀏覽器上網,一邊在聽敲程式碼,一邊用 Markdown 寫部落格,這就是多工,至少同時有 3 個任務正在執行。當然還有很多工悄悄地在後臺同時執行著,只是桌面上沒有顯示而已。對於作業系統來說,一個任務就是一個程式(Process),比如開啟一個瀏覽器就是啟動一個瀏覽器程式,開啟 PyCharm 就是一個啟動了一個 PtCharm 程式,開啟 Markdown 就是啟動了一個 Md 的程式。

雖然現在多核 CPU 已經非常普及了。可是由於 CPU 執行程式碼都是順序執行的,這時候我們就會有疑問,單核 CPU 是怎麼執行多工的呢?

其實就是作業系統輪流讓各個任務交替執行,任務 1 執行 0.01 秒,切換到任務 2 ,任務 2 執行 0.01 秒,再切換到任務 3 ,執行 0.01秒……這樣反覆執行下去。表面上看,每個任務都是交替執行的,但是,由於 CPU的執行速度實在是太快了,我們肉眼和感覺上沒法識別出來,就像所有任務都在同時執行一樣。

真正的並行執行多工只能在多核 CPU 上實現,但是,由於任務數量遠遠多於 CPU 的核心數量,所以,作業系統也會自動把很多工輪流排程到每個核心上執行。

有些程式不僅僅只是幹一件事的啊,比如瀏覽器,我們可以播放時視訊,播放音訊,看文章,編輯文章等等,其實這些都是在瀏覽器程式中的子任務。在一個程式內部,要同時幹多件事,就需要同時執行多個“子任務”,我們把程式內的這些“子任務”稱為執行緒(Thread)。

由於每個程式至少要幹一件事,所以,一個程式至少有一個執行緒。當然,一個程式也可以有多個執行緒,多個執行緒可以同時執行,多執行緒的執行方式和多程式是一樣的,也是由作業系統在多個執行緒之間快速切換,讓每個執行緒都短暫地交替執行,看起來就像同時執行一樣。

那麼在 Python 中我們要同時執行多個任務怎麼辦?

有兩種解決方案:

一種是啟動多個程式,每個程式雖然只有一個執行緒,但多個程式可以一塊執行多個任務。

還有一種方法是啟動一個程式,在一個程式內啟動多個執行緒,這樣,多個執行緒也可以一塊執行多個任務。

當然還有第三種方法,就是啟動多個程式,每個程式再啟動多個執行緒,這樣同時執行的任務就更多了,當然這種模型更復雜,實際很少採用。

總結一下就是,多工的實現有3種方式:

  • 多程式模式;
  • 多執行緒模式;
  • 多程式+多執行緒模式。

同時執行多個任務通常各個任務之間並不是沒有關聯的,而是需要相互通訊和協調,有時,任務 1 必須暫停等待任務 2 完成後才能繼續執行,有時,任務 3 和任務 4 又不能同時執行,所以,多程式和多執行緒的程式的複雜度要遠遠高於我們前面寫的單程式單執行緒的程式。

因為複雜度高,除錯困難,所以,不是迫不得已,我們也不想編寫多工。但是,有很多時候,沒有多工還真不行。想想在電腦上看電影,就必須由一個執行緒播放視訊,另一個執行緒播放音訊,否則,單執行緒實現的話就只能先把視訊播放完再播放音訊,或者先把音訊播放完再播放視訊,這顯然是不行的。

多執行緒程式設計

其實建立執行緒之後,執行緒並不是始終保持一個狀態的,其狀態大概如下:

  • New 建立
  • Runnable 就緒。等待排程
  • Running 執行
  • Blocked 阻塞。阻塞可能在 Wait Locked Sleeping
  • Dead 消亡

執行緒有著不同的狀態,也有不同的型別。大致可分為:

  • 主執行緒
  • 子執行緒
  • 守護執行緒(後臺執行緒)
  • 前臺執行緒

簡單瞭解完這些之後,我們開始看看具體的程式碼使用了。

1、執行緒的建立

Python 提供兩個模組進行多執行緒的操作,分別是 threadthreading

前者是比較低階的模組,用於更底層的操作,一般應用級別的開發不常用。

#!/usr/bin/env python3
# -*- coding: UTF-8 -*-

import time
import threading


class MyThread(threading.Thread):
    def run(self):
        for i in range(5):
            print('thread {}, @number: {}'.format(self.name, i))
            time.sleep(1)


def main():
    print("Start main threading")

    # 建立三個執行緒
    threads = [MyThread() for i in range(3)]
    # 啟動三個執行緒
    for t in threads:
        t.start()

    print("End Main threading")


if __name__ == '__main__':
    main()

複製程式碼

執行結果:

Start main threading
thread Thread-1, @number: 0
thread Thread-2, @number: 0
thread Thread-3, @number: 0
End Main threading
thread Thread-2, @number: 1
thread Thread-1, @number: 1
thread Thread-3, @number: 1
thread Thread-1, @number: 2
thread Thread-3, @number: 2
thread Thread-2, @number: 2
thread Thread-2, @number: 3
thread Thread-3, @number: 3
thread Thread-1, @number: 3
thread Thread-3, @number: 4
thread Thread-2, @number: 4
thread Thread-1, @number: 4
複製程式碼

注意喔,這裡不同的環境輸出的結果肯定是不一樣的。

2、執行緒合併(join方法)

上面的示例列印出來的結果來看,主執行緒結束後,子執行緒還在執行。那麼我們需要主執行緒要等待子執行緒執行完後,再退出,要怎麼辦呢?

這時候,就需要用到 join 方法了。

在上面的例子,新增一段程式碼,具體如下:

#!/usr/bin/env python3
# -*- coding: UTF-8 -*-

import time
import threading


class MyThread(threading.Thread):
    def run(self):
        for i in range(5):
            print('thread {}, @number: {}'.format(self.name, i))
            time.sleep(1)


def main():
    print("Start main threading")

    # 建立三個執行緒
    threads = [MyThread() for i in range(3)]
    # 啟動三個執行緒
    for t in threads:
        t.start()

    # 一次讓新建立的執行緒執行 join
    for t in threads:
        t.join()

    print("End Main threading")


if __name__ == '__main__':
    main()

複製程式碼

從列印的結果,可以清楚看到,相比上面示例列印出來的結果,主執行緒是在等待子執行緒執行結束後才結束的。

Start main threading
thread Thread-1, @number: 0
thread Thread-2, @number: 0
thread Thread-3, @number: 0
thread Thread-1, @number: 1
thread Thread-3, @number: 1
thread Thread-2, @number: 1
thread Thread-2, @number: 2
thread Thread-1, @number: 2
thread Thread-3, @number: 2
thread Thread-2, @number: 3
thread Thread-1, @number: 3
thread Thread-3, @number: 3
thread Thread-3, @number: 4
thread Thread-2, @number: 4
thread Thread-1, @number: 4
End Main threading

複製程式碼

3、執行緒同步與互斥鎖

使用執行緒載入獲取資料,通常都會造成資料不同步的情況。當然,這時候我們可以給資源進行加鎖,也就是訪問資源的執行緒需要獲得鎖才能訪問。

其中 threading 模組給我們提供了一個 Lock 功能。

lock = threading.Lock()
複製程式碼

線上程中獲取鎖

lock.acquire()
複製程式碼

使用完成後,我們肯定需要釋放鎖

lock.release()
複製程式碼

當然為了支援在同一執行緒中多次請求同一資源,Python 提供了可重入鎖(RLock)。RLock 內部維護著一個 Lock 和一個 counter 變數,counter 記錄了 acquire 的次數,從而使得資源可以被多次 require。直到一個執行緒所有的 acquire 都被 release,其他的執行緒才能獲得資源。

那麼怎麼建立重入鎖呢?也是一句程式碼的事情:

r_lock = threading.RLock()
複製程式碼

4、Condition 條件變數

實用鎖可以達到執行緒同步,但是在更復雜的環境,需要針對鎖進行一些條件判斷。Python 提供了 Condition 物件。使用 Condition 物件可以在某些事件觸發或者達到特定的條件後才處理資料,Condition 除了具有 Lock 物件的 acquire 方法和 release 方法外,還提供了 wait 和 notify 方法。執行緒首先 acquire 一個條件變數鎖。如果條件不足,則該執行緒 wait,如果滿足就執行執行緒,甚至可以 notify 其他執行緒。其他處於 wait 狀態的執行緒接到通知後會重新判斷條件。

其中條件變數可以看成不同的執行緒先後 acquire 獲得鎖,如果不滿足條件,可以理解為被扔到一個( Lock 或 RLock )的 waiting 池。直達其他執行緒 notify 之後再重新判斷條件。不斷的重複這一過程,從而解決複雜的同步問題。

Condition

該模式常用於生產者消費者模式,具體看看下面線上購物買家和賣家的示例:

#!/usr/bin/env python3
# -*- coding: UTF-8 -*-

import threading, time


class Consumer(threading.Thread):
    def __init__(self, cond, name):
        # 初始化
        super(Consumer, self).__init__()
        self.cond = cond
        self.name = name

    def run(self):
        # 確保先執行Seeker中的方法
        time.sleep(1)
        self.cond.acquire()
        print(self.name + ': 我這兩件商品一起買,可以便宜點嗎')
        self.cond.notify()
        self.cond.wait()
        print(self.name + ': 我已經提交訂單了,你修改下價格')
        self.cond.notify()
        self.cond.wait()
        print(self.name + ': 收到,我支付成功了')
        self.cond.notify()
        self.cond.release()
        print(self.name + ': 等待收貨')


class Producer(threading.Thread):
    def __init__(self, cond, name):
        super(Producer, self).__init__()
        self.cond = cond
        self.name = name

    def run(self):
        self.cond.acquire()
        # 釋放對瑣的佔用,同時執行緒掛起在這裡,直到被 notify 並重新佔有瑣。
        self.cond.wait()
        print(self.name + ': 可以的,你提交訂單吧')
        self.cond.notify()
        self.cond.wait()
        print(self.name + ': 好了,已經修改了')
        self.cond.notify()
        self.cond.wait()
        print(self.name + ': 嗯,收款成功,馬上給你發貨')
        self.cond.release()
        print(self.name + ': 發貨商品')


cond = threading.Condition()
consumer = Consumer(cond, '買家(兩點水)')
producer = Producer(cond, '賣家(三點水)')
consumer.start()
producer.start()

複製程式碼

輸出的結果如下:

買家(兩點水): 我這兩件商品一起買,可以便宜點嗎
賣家(三點水): 可以的,你提交訂單吧
買家(兩點水): 我已經提交訂單了,你修改下價格
賣家(三點水): 好了,已經修改了
買家(兩點水): 收到,我支付成功了
買家(兩點水): 等待收貨
賣家(三點水): 嗯,收款成功,馬上給你發貨
賣家(三點水): 發貨商品
複製程式碼

5、執行緒間通訊

如果程式中有多個執行緒,這些執行緒避免不了需要相互通訊的。那麼我們怎樣在這些執行緒之間安全地交換資訊或資料呢?

從一個執行緒向另一個執行緒傳送資料最安全的方式可能就是使用 queue 庫中的佇列了。建立一個被多個執行緒共享的 Queue 物件,這些執行緒通過使用 put()get() 操作來向佇列中新增或者刪除元素。

# -*- coding: UTF-8 -*-
from queue import Queue
from threading import Thread

isRead = True


def write(q):
    # 寫資料程式
    for value in ['兩點水', '三點水', '四點水']:
        print('寫進 Queue 的值為:{0}'.format(value))
        q.put(value)


def read(q):
    # 讀取資料程式
    while isRead:
        value = q.get(True)
        print('從 Queue 讀取的值為:{0}'.format(value))


if __name__ == '__main__':
    q = Queue()
    t1 = Thread(target=write, args=(q,))
    t2 = Thread(target=read, args=(q,))
    t1.start()
    t2.start()
複製程式碼

輸出的結果如下:

寫進 Queue 的值為:兩點水
寫進 Queue 的值為:三點水
從 Queue 讀取的值為:兩點水
寫進 Queue 的值為:四點水
從 Queue 讀取的值為:三點水
從 Queue 讀取的值為:四點水
複製程式碼

Python 還提供了 Event 物件用於執行緒間通訊,它是由執行緒設定的訊號標誌,如果訊號標誌位真,則其他執行緒等待直到訊號接觸。

Event 物件實現了簡單的執行緒通訊機制,它提供了設定訊號,清楚訊號,等待等用於實現執行緒間的通訊。

  • 設定訊號

使用 Event 的 set() 方法可以設定 Event 物件內部的訊號標誌為真。Event 物件提供了 isSe() 方法來判斷其內部訊號標誌的狀態。當使用 event 物件的 set() 方法後,isSet() 方法返回真

  • 清除訊號

使用 Event 物件的 clear() 方法可以清除 Event 物件內部的訊號標誌,即將其設為假,當使用 Event 的 clear 方法後,isSet() 方法返回假

  • 等待

Event 物件 wait 的方法只有在內部訊號為真的時候才會很快的執行並完成返回。當 Event 物件的內部訊號標誌位假時,則 wait 方法一直等待到其為真時才返回。

示例:

# -*- coding: UTF-8 -*-

import threading


class mThread(threading.Thread):
    def __init__(self, threadname):
        threading.Thread.__init__(self, name=threadname)

    def run(self):
        # 使用全域性Event物件
        global event
        # 判斷Event物件內部訊號標誌
        if event.isSet():
            event.clear()
            event.wait()
            print(self.getName())
        else:
            print(self.getName())
            # 設定Event物件內部訊號標誌
            event.set()

# 生成Event物件
event = threading.Event()
# 設定Event物件內部訊號標誌
event.set()
t1 = []
for i in range(10):
    t = mThread(str(i))
    # 生成執行緒列表
    t1.append(t)

for i in t1:
    # 執行執行緒
    i.start()

複製程式碼

輸出的結果如下:

1
0
3
2
5
4
7
6
9
8
複製程式碼

6、後臺執行緒

預設情況下,主執行緒退出之後,即使子執行緒沒有 join。那麼主執行緒結束後,子執行緒也依然會繼續執行。如果希望主執行緒退出後,其子執行緒也退出而不再執行,則需要設定子執行緒為後臺執行緒。Python 提供了 setDeamon 方法。

程式

Python 中的多執行緒其實並不是真正的多執行緒,如果想要充分地使用多核 CPU 的資源,在 Python 中大部分情況需要使用多程式。Python 提供了非常好用的多程式包 multiprocessing,只需要定義一個函式,Python 會完成其他所有事情。藉助這個包,可以輕鬆完成從單程式到併發執行的轉換。multiprocessing 支援子程式、通訊和共享資料、執行不同形式的同步,提供了 Process、Queue、Pipe、Lock 等元件。

1、類 Process

建立程式的類:Process([group [, target [, name [, args [, kwargs]]]]])

  • target 表示呼叫物件
  • args 表示呼叫物件的位置引數元組
  • kwargs表示呼叫物件的字典
  • name為別名
  • group實質上不使用

下面看一個建立函式並將其作為多個程式的例子:

#!/usr/bin/env python3
# -*- coding: UTF-8 -*-

import multiprocessing
import time


def worker(interval, name):
    print(name + '【start】')
    time.sleep(interval)
    print(name + '【end】')


if __name__ == "__main__":
    p1 = multiprocessing.Process(target=worker, args=(2, '兩點水1'))
    p2 = multiprocessing.Process(target=worker, args=(3, '兩點水2'))
    p3 = multiprocessing.Process(target=worker, args=(4, '兩點水3'))

    p1.start()
    p2.start()
    p3.start()

    print("The number of CPU is:" + str(multiprocessing.cpu_count()))
    for p in multiprocessing.active_children():
        print("child   p.name:" + p.name + "\tp.id" + str(p.pid))
    print("END!!!!!!!!!!!!!!!!!")

複製程式碼

輸出的結果:

多程式輸出結果

2、把程式建立成類

當然我們也可以把程式建立成一個類,如下面的例子,當程式 p 呼叫 start() 時,自動呼叫 run() 方法。

# -*- coding: UTF-8 -*-

import multiprocessing
import time


class ClockProcess(multiprocessing.Process):
    def __init__(self, interval):
        multiprocessing.Process.__init__(self)
        self.interval = interval

    def run(self):
        n = 5
        while n > 0:
            print("當前時間: {0}".format(time.ctime()))
            time.sleep(self.interval)
            n -= 1


if __name__ == '__main__':
    p = ClockProcess(3)
    p.start()

複製程式碼

輸出結果如下:

建立程式類

3、daemon 屬性

想知道 daemon 屬性有什麼用,看下下面兩個例子吧,一個加了 daemon 屬性,一個沒有加,對比輸出的結果:

沒有加 deamon 屬性的例子:

# -*- coding: UTF-8 -*-
import multiprocessing
import time


def worker(interval):
    print('工作開始時間:{0}'.format(time.ctime()))
    time.sleep(interval)
    print('工作結果時間:{0}'.format(time.ctime()))


if __name__ == '__main__':
    p = multiprocessing.Process(target=worker, args=(3,))
    p.start()
    print('【EMD】')

複製程式碼

輸出結果:

【EMD】
工作開始時間:Mon Oct  9 17:47:06 2017
工作結果時間:Mon Oct  9 17:47:09 2017
複製程式碼

在上面示例中,程式 p 新增 daemon 屬性:

# -*- coding: UTF-8 -*-

import multiprocessing
import time


def worker(interval):
    print('工作開始時間:{0}'.format(time.ctime()))
    time.sleep(interval)
    print('工作結果時間:{0}'.format(time.ctime()))


if __name__ == '__main__':
    p = multiprocessing.Process(target=worker, args=(3,))
    p.daemon = True
    p.start()
    print('【EMD】')
複製程式碼

輸出結果:

【EMD】
複製程式碼

根據輸出結果可見,如果在子程式中新增了 daemon 屬性,那麼當主程式結束的時候,子程式也會跟著結束。所以沒有列印子程式的資訊。

4、join 方法

結合上面的例子繼續,如果我們想要讓子執行緒執行完該怎麼做呢?

那麼我們可以用到 join 方法,join 方法的主要作用是:阻塞當前程式,直到呼叫 join 方法的那個程式執行完,再繼續執行當前程式。

因此看下加了 join 方法的例子:

import multiprocessing
import time


def worker(interval):
    print('工作開始時間:{0}'.format(time.ctime()))
    time.sleep(interval)
    print('工作結果時間:{0}'.format(time.ctime()))


if __name__ == '__main__':
    p = multiprocessing.Process(target=worker, args=(3,))
    p.daemon = True
    p.start()
    p.join()
    print('【EMD】')
複製程式碼

輸出的結果:

工作開始時間:Tue Oct 10 11:30:08 2017
工作結果時間:Tue Oct 10 11:30:11 2017
【EMD】
複製程式碼

5、Pool

如果需要很多的子程式,難道我們需要一個一個的去建立嗎?

當然不用,我們可以使用程式池的方法批量建立子程式。

例子如下:

# -*- coding: UTF-8 -*-

from multiprocessing import Pool
import os, time, random


def long_time_task(name):
    print('程式的名稱:{0} ;程式的PID: {1} '.format(name, os.getpid()))
    start = time.time()
    time.sleep(random.random() * 3)
    end = time.time()
    print('程式 {0} 執行了 {1} 秒'.format(name, (end - start)))


if __name__ == '__main__':
    print('主程式的 PID:{0}'.format(os.getpid()))
    p = Pool(4)
    for i in range(6):
        p.apply_async(long_time_task, args=(i,))
    p.close()
    # 等待所有子程式結束後在關閉主程式
    p.join()
    print('【End】')
複製程式碼

輸出的結果如下:

主程式的 PID:7256
程式的名稱:0 ;程式的PID: 1492 
程式的名稱:1 ;程式的PID: 12232 
程式的名稱:2 ;程式的PID: 4332 
程式的名稱:3 ;程式的PID: 11604 
程式 2 執行了 0.6500370502471924 秒
程式的名稱:4 ;程式的PID: 4332 
程式 1 執行了 1.0830621719360352 秒
程式的名稱:5 ;程式的PID: 12232 
程式 5 執行了 0.029001712799072266 秒
程式 4 執行了 0.9720554351806641 秒
程式 0 執行了 2.3181326389312744 秒
程式 3 執行了 2.5331451892852783 秒
【End】
複製程式碼

這裡有一點需要注意: Pool 物件呼叫 join() 方法會等待所有子程式執行完畢,呼叫 join() 之前必須先呼叫 close() ,呼叫close() 之後就不能繼續新增新的 Process 了。

請注意輸出的結果,子程式 0,1,2,3是立刻執行的,而子程式 4 要等待前面某個子程式完成後才執行,這是因為 Pool 的預設大小在我的電腦上是 4,因此,最多同時執行 4 個程式。這是 Pool 有意設計的限制,並不是作業系統的限制。如果改成:

p = Pool(5)
複製程式碼

就可以同時跑 5 個程式。

6、程式間通訊

Process 之間肯定是需要通訊的,作業系統提供了很多機制來實現程式間的通訊。Python 的 multiprocessing 模組包裝了底層的機制,提供了Queue、Pipes 等多種方式來交換資料。

以 Queue 為例,在父程式中建立兩個子程式,一個往 Queue 裡寫資料,一個從 Queue 裡讀資料:

#!/usr/bin/env python3
# -*- coding: UTF-8 -*-

from multiprocessing import Process, Queue
import os, time, random


def write(q):
    # 寫資料程式
    print('寫程式的PID:{0}'.format(os.getpid()))
    for value in ['兩點水', '三點水', '四點水']:
        print('寫進 Queue 的值為:{0}'.format(value))
        q.put(value)
        time.sleep(random.random())


def read(q):
    # 讀取資料程式
    print('讀程式的PID:{0}'.format(os.getpid()))
    while True:
        value = q.get(True)
        print('從 Queue 讀取的值為:{0}'.format(value))


if __name__ == '__main__':
    # 父程式建立 Queue,並傳給各個子程式
    q = Queue()
    pw = Process(target=write, args=(q,))
    pr = Process(target=read, args=(q,))
    # 啟動子程式 pw
    pw.start()
    # 啟動子程式pr
    pr.start()
    # 等待pw結束:
    pw.join()
    # pr 程式裡是死迴圈,無法等待其結束,只能強行終止
    pr.terminate()

複製程式碼

輸出的結果為:

讀程式的PID:13208
寫程式的PID:10864
寫進 Queue 的值為:兩點水
從 Queue 讀取的值為:兩點水
寫進 Queue 的值為:三點水
從 Queue 讀取的值為:三點水
寫進 Queue 的值為:四點水
從 Queue 讀取的值為:四點水
複製程式碼

相關文章