如果你從未聽(tīng)過(guò)線程梭冠,這是一個(gè)基本的描述胸遇。
- 線程是操作系統(tǒng)(OS)提供的特性
- 提供給軟件開(kāi)發(fā)人員料祠,以便他們可以向操作系統(tǒng)表明程序的哪些部分可以并行運(yùn)行
- 操作系統(tǒng)決定如何與每個(gè)部分共享CPU資源,就像操作系統(tǒng)決定與同時(shí)運(yùn)行的所有其他不同程序(進(jìn)程)共享CPU資源一樣。
既然你正在讀一本異步通訊的書(shū)具伍,這一定是我要告訴你的部分藕筋,"線程是可怕的纵散,你永遠(yuǎn)不應(yīng)該使用它們,對(duì)嗎?" 不幸的是隐圾,情況并非如此簡(jiǎn)單伍掀。我們需要權(quán)衡使用線程的好處和風(fēng)險(xiǎn),就像任何技術(shù)選擇一樣暇藏。
這本書(shū)不應(yīng)該是關(guān)于線程的.但是這里有兩個(gè)問(wèn)題:異步是作為線程的替代提供的蜜笤,因此如果不進(jìn)行一些比較,就很難理解其價(jià)值主張;即使在使用異步時(shí)盐碱,您仍然可能需要處理線程和進(jìn)程把兔,因此您需要了解一些關(guān)于線程的知識(shí)沪伙。
Benefits of Threading(線程的好處)
這些是線程的主要好處:
- 代碼易讀
您的代碼可以并發(fā)地運(yùn)行,但是仍然可以以非常簡(jiǎn)單的县好、自頂向下的線性命令序列進(jìn)行設(shè)置围橡。and this is key—you can pretend, within the body of your functions, that no concurrency is happening. - 并發(fā)下的內(nèi)存共享
您的代碼可以利用多個(gè)cpu,同時(shí)仍然有線程共享內(nèi)存聘惦。這在許多工作負(fù)載中非常重要某饰,例如儒恋,在不同進(jìn)程的不同內(nèi)存空間之間移動(dòng)大量數(shù)據(jù)的成本太高善绎。 - 專(zhuān)有技術(shù)和現(xiàn)有規(guī)范
有大量的知識(shí)和最佳實(shí)踐可用于編寫(xiě)線程應(yīng)用程序。這里還有大量現(xiàn)有的“阻塞”代碼诫尽,它們依賴(lài)于多線程進(jìn)行并發(fā)操作禀酱。
現(xiàn)在,對(duì)于Python牧嫉,關(guān)于并行性的觀點(diǎn)是有問(wèn)題的剂跟,因?yàn)镻ython解釋器使用全局鎖,稱(chēng)為全局解釋器鎖(GIL)酣藻,以保護(hù)解釋器本身的內(nèi)部狀態(tài)曹洽。也就是說(shuō),它提供了保護(hù)辽剧,以避免多個(gè)線程之間競(jìng)爭(zhēng)條件的潛在災(zāi)難性影響送淆。鎖的一個(gè)副作用是,它最終會(huì)將程序中的所有線程固定在一個(gè)CPU上怕轿。正如您可以想象的那樣偷崩,這否定了任何并行性性能好處(除非您使用Cython或Numba之類(lèi)的工具來(lái)繞過(guò)限制)。
然而撞羽,關(guān)于可感知的簡(jiǎn)單性阐斜,第一點(diǎn)很重要:在Python中使用線程非常簡(jiǎn)單,如果您以前沒(méi)有遇到過(guò)難以置信的競(jìng)爭(zhēng)條件錯(cuò)誤诀紊,線程提供了一個(gè)非常吸引人的并發(fā)模型谒出。即使您在過(guò)去經(jīng)歷過(guò)痛苦,線程仍然是一個(gè)引人注目的選擇邻奠,因?yàn)槟赡芤呀?jīng)學(xué)會(huì)了(艱難的方法)如何保持代碼簡(jiǎn)單和安全笤喳。
我在這里沒(méi)有篇幅來(lái)討論更安全的線程編程.但是一般來(lái)說(shuō),使用線程的最佳實(shí)踐是從并發(fā)中使用ThreadPoolExecutor
類(lèi)惕澎。concurrent.futures
.通過(guò)submit()傳遞所有需要的數(shù)據(jù)方法莉测。
from concurrent.futures import ThreadPoolExecutor as Executor
import time
def worker(data):
# process the data
pass
data = 'your-input-data'
with Executor(max_workers=10) as exe:
future = exe.submit(worker, data)
ThreadPoolExecutor提供了一個(gè)非常簡(jiǎn)單的接口,用于在線程中運(yùn)行函數(shù)唧喉。如果需要捣卤,可以使用ProcessPoolExecutor將線程池轉(zhuǎn)換為子進(jìn)程池忍抽。它們有相同的api,這意味著對(duì)代碼的改動(dòng)是最小的董朝。executor API 同樣使用在異步中鸠项。
通常情況下,您會(huì)希望您的任務(wù)是短期的子姜,這樣當(dāng)您的程序需要關(guān)閉時(shí)祟绊,您可以簡(jiǎn)單地調(diào)用Executor.shutdown(wait=True)并等待一兩秒鐘,以允許執(zhí)行程序完成哥捕。
最重要的是:如果可能的話牧抽,您應(yīng)該盡量防止您的線程代碼(在前面的示例中,worker()函數(shù))訪問(wèn)或?qū)懭肴魏稳肿兞?
線程的不足
線程的缺點(diǎn)已經(jīng)在其他一些地方提到過(guò)遥赚,但為了完整起見(jiàn)扬舒,我們還是在這里收集它們:
- 線程是很困難的
線程錯(cuò)誤和線程程序中的競(jìng)爭(zhēng)條件是最難修復(fù)的錯(cuò)誤類(lèi)型。有了經(jīng)驗(yàn)凫佛,就有可能設(shè)計(jì)出不容易出現(xiàn)這些問(wèn)題的新軟件讲坎。但在不經(jīng)測(cè)試的、未經(jīng)精心設(shè)計(jì)的軟件中愧薛,它們幾乎是不可能修復(fù)的晨炕,即使是專(zhuān)家 - 線程是資源密集型
線程需要額外的操作系統(tǒng)資源來(lái)創(chuàng)建,例如預(yù)先分配的每個(gè)線程的堆椇谅空間瓮栗,它預(yù)先消耗進(jìn)程虛擬內(nèi)存。這是32位操作系統(tǒng)的一個(gè)大問(wèn)題碘箍,因?yàn)槊總€(gè)進(jìn)程的尋址空間限制在3GB遵馆。如今,隨著64位操作系統(tǒng)的廣泛使用丰榴,虛擬內(nèi)存不再像以前那樣珍貴(虛擬內(nèi)存的可尋址空間通常是48位;即货邓。256 TiB) 。 在現(xiàn)代的桌面操作系統(tǒng)中四濒,每個(gè)線程的堆椈豢觯空間所需的物理內(nèi)存甚至在需要時(shí)才由操作系統(tǒng)分配,包括每個(gè)線程的堆椀馏。空間戈二。例如,在一個(gè)擁有8 GB內(nèi)存的現(xiàn)代64位Fedora 29 Linux上喳资,用這個(gè)簡(jiǎn)短的代碼片段創(chuàng)建了10,000個(gè)不做任何事情的線程:
import os
from time import sleep
from threading import Thread
threads = [
Thread(target=lambda: sleep(60)) for i in range(1000)
]
[t.start() for t in threads]
print(f'PID = {os.getpid()}')
[t.join() for t in threads]
預(yù)先分配的虛擬內(nèi)存是驚人的~ 80gb(因?yàn)槊總€(gè)線程有8mb的堆椌蹩裕空間!),但是駐留內(nèi)存只有~ 130mb
仆邓。在32位Linux系統(tǒng)上鲜滩,由于3 GB的用戶(hù)空間地址空間限制伴鳖,我無(wú)法創(chuàng)建這么多空間,無(wú)論實(shí)際物理內(nèi)存的消耗是多少徙硅。為了在32位系統(tǒng)上解決這個(gè)問(wèn)題榜聂,有時(shí)需要減少預(yù)配置的堆棧大小,現(xiàn)在在Python中仍然可以這樣做嗓蘑,使用thread .stack_size([size])
.顯然须肆,減少堆棧大小會(huì)影響運(yùn)行時(shí)的安全性,這與函數(shù)調(diào)用可以嵌套的程度有關(guān)桩皿,包括遞歸.單線程協(xié)程沒(méi)有這些問(wèn)題豌汇,是并發(fā)I/O的一個(gè)更好的選擇。
- 線程會(huì)影響吞吐量
在非常高的并發(fā)級(jí)別(例如业簿,> 5000個(gè)線程)瘤礁,由于上下文切換
成本,也會(huì)對(duì)吞吐量產(chǎn)生影響.假設(shè)你能弄明白如何配置你的操作系統(tǒng)梅尤,甚至允許你創(chuàng)建那么多線程.在最近的macOS版本中,例如岩调,為了測(cè)試前面的10,000個(gè)do-nothing-threads示例巷燥,我完全放棄了提高限制的嘗試 - 線程是死板的
操作系統(tǒng)將不斷地與所有線程共享CPU時(shí)間,而不管某個(gè)線程是否準(zhǔn)備好工作.例如号枕,一個(gè)線程可能正在等待套接字上的數(shù)據(jù)缰揪,但是OS調(diào)度器仍然可能在任何實(shí)際工作需要完成之前在該線程之間來(lái)回切換數(shù)千次。(在異步世界中葱淳,select()
系統(tǒng)調(diào)用用于檢查等待套接字的協(xié)同程序是否需要切換; 否則钝腺,協(xié)同程序甚至不會(huì)被喚醒,完全避免了任何轉(zhuǎn)換成本赞厕。)
這都不是新的知識(shí)艳狐,線程作為編程模型的問(wèn)題也不是特定于平臺(tái)的。例如皿桑,這是關(guān)于線程的Microsoft Visual c++文檔:
Windows API中的中央并發(fā)機(jī)制是線程毫目。通常使用CreateThread函數(shù)創(chuàng)建線程。雖然線程的創(chuàng)建和使用相對(duì)容易诲侮,但是操作系統(tǒng)會(huì)分配大量的時(shí)間和其他資源來(lái)管理它們镀虐。此外,盡管每個(gè)線程都保證與具有相同優(yōu)先級(jí)的任何其他線程接收相同的執(zhí)行時(shí)間沟绪,但是相關(guān)的開(kāi)銷(xiāo)要求您創(chuàng)建足夠大的任務(wù)刮便。對(duì)于更小或更細(xì)粒度的任務(wù),與并發(fā)相關(guān)的開(kāi)銷(xiāo)可能會(huì)超過(guò)并行運(yùn)行任務(wù)的好處绽慈。
但是——我聽(tīng)說(shuō)你們抗議了——這是Windows恨旱,對(duì)吧?Unix系統(tǒng)肯定沒(méi)有這些問(wèn)題吧?下面是來(lái)自Mac開(kāi)發(fā)者庫(kù)的線程編程指南的類(lèi)似建議:
在內(nèi)存使用和性能方面抄肖,線程對(duì)程序(和系統(tǒng))有實(shí)際的成本。每個(gè)線程都需要在內(nèi)核內(nèi)存空間和程序的內(nèi)存空間中分配內(nèi)存窖杀。管理線程和協(xié)調(diào)其調(diào)度所需的核心結(jié)構(gòu)使用有線內(nèi)存存儲(chǔ)在內(nèi)核中漓摩。線程的堆棧空間和每個(gè)線程的數(shù)據(jù)存儲(chǔ)在程序的內(nèi)存空間中入客。大多數(shù)這些結(jié)構(gòu)都是在您第一次創(chuàng)建thread-a進(jìn)程時(shí)創(chuàng)建并初始化的管毙,由于需要與內(nèi)核進(jìn)行交互,這個(gè)進(jìn)程的開(kāi)銷(xiāo)相對(duì)較大桌硫。
它們?cè)?a target="_blank">并發(fā)編程指南中有更進(jìn)一步說(shuō)明:
在過(guò)去夭咬,將并發(fā)引入應(yīng)用程序需要?jiǎng)?chuàng)建一個(gè)或多個(gè)額外的線程。不幸的是铆隘,編寫(xiě)線程代碼具有挑戰(zhàn)性卓舵。線程是一種必須手動(dòng)管理的低級(jí)工具“蚰疲考慮到應(yīng)用程序的最佳線程數(shù)量可以根據(jù)當(dāng)前系統(tǒng)負(fù)載和底層硬件動(dòng)態(tài)更改掏湾,實(shí)現(xiàn)正確的線程解決方案變得非常困難,但不是不可能實(shí)現(xiàn)的肿嘲。此外融击,通常與線程一起使用的同步機(jī)制增加了軟件設(shè)計(jì)的復(fù)雜性和風(fēng)險(xiǎn),卻不能保證提高性能雳窟。
這些主題貫穿始終:
- 線程使代碼難以推理尊浪。
- 線程是大規(guī)模并發(fā)(數(shù)千個(gè)并發(fā)任務(wù))的低效模型
接下來(lái),讓我們看一個(gè)關(guān)于線程的案例研究封救,它強(qiáng)調(diào)了第一點(diǎn)拇涤,也是最重要的一點(diǎn)。
案例研究:機(jī)器人和餐具
其次誉结,也是更重要的一點(diǎn)鹅士,我們過(guò)去(現(xiàn)在也仍然不相信)標(biāo)準(zhǔn)的多線程模型,它是共享內(nèi)存搶占式并發(fā):我們?nèi)匀徽J(rèn)為沒(méi)有人能夠在“a = a + 1”是不確定的語(yǔ)言中編寫(xiě)正確的程序搓彻。
我講了一個(gè)餐廳的故事如绸,里面的類(lèi)人機(jī)器人——ThreadBots——做了所有的工作。在這個(gè)比喻里旭贬,每個(gè)工人健康就是一個(gè)線程怔接。在下面的案例中,我們將了解為什么線程被認(rèn)為是不安全的稀轨。
# ThreadBot for table service
import threading
from queue import Queue
from attr import attrs, attrib # making class creation easy.
# attrs 初始化裝飾器
@attrs
class Cutlery:
knives = attrib(default=0)
forks = attrib(default=0)
def give(self, to:'Cutlery', knives=0, forks=0):
self.change(-knives, -forks)
to.change(knives, forks)
def change(self, knives, forks):
self.knives += knives
self.forks += forks
# Thread 子類(lèi)
class ThreadBot(threading.Thread):
tasks = Queue()
def __int__(self):
super().__init__(target=self.manage_table)
# bot 等待table扼脐,同時(shí)響應(yīng)cutlery
self.cutlery = Cutlery(knives=0, forks=0)
# 機(jī)器人還將被分配任務(wù)。它們將被添加到這個(gè)任務(wù)隊(duì)列
# 然后機(jī)器人將在其主處理循環(huán)期間執(zhí)行它們
# self.tasks = Queue()
# 這個(gè)機(jī)器人的主要程序是這個(gè)無(wú)限循環(huán)。
# 如果你需要關(guān)閉一個(gè)bot瓦侮,你必須給他們關(guān)閉任務(wù)艰赞。
def manage_table(self):
while True:
task = self.tasks.get()
if task == 'prepare table':
kitchen.give(to=self.cutlery, knives=4, forks=4)
elif task == 'clear table':
self.cutlery.give(to=kitchen, knives=4, forks=4)
elif task == 'shutdown':
return
kitchen = Cutlery(knives=100, forks=100)
bots = [ThreadBot() for i in range(10)] # 創(chuàng)建10個(gè)
import sys
for bot in bots:
for i in range(int(sys.argv[1])):
# print(bot,type(bot))
bot.tasks.put('prepare table')
bot.tasks.put('clear table')
bot.tasks.put('shutdown')
print('Kitchen inventory before service: ', kitchen)
for bot in bots:
bot.start()
for bot in bots:
bot.join()
print("kitchen inventory after service: ", kitchen)