Ачааллж байна...
Энэ бүлгийн хоёр хичээлд нэг үр дүн давтагдсан. Thread хүлээлтийг гайхалтай давхцуулсан: таван татаж авалт нэг секундэд. Гэвч тооцооллыг огт хурдасгаагүй: таван crunch нэг thread-тэй ч, таван thread-тэй ч 1.6 секунд. Бид шалтгааныг GIL гэж нэрлэсэн, гэхдээ тайлбарлаагүй. Энэ хичээл бүлгийг дуусгаж, тэр нууцыг бүрэн тайлна.
Хичээлийн гурван хэсэг бий. Нэгдүгээрт, GIL юу хамгаалдаг, яагаад Python-д хорин гаруй жил оршсон бэ. Энэ бол санамсаргүй сул тал биш, тодорхой шалтгаантай инженерийн шийдвэр. Хоёрдугаарт, Python-ий сүүлийн хувилбаруудад юу өөрчлөгдөж байна: GIL-гүй free-threaded build туршилтаас албан ёсны сонголт болсон. Гуравдугаарт, CPU-bound ажлыг өнөөдөр, ямар ч Python дээр найдвартай хурдасгадаг стандарт хэрэгсэл: ProcessPoolExecutor. Гайхалтай нь өмнөх хичээлийн бүх хэв маяг, map, submit, as_completed, Future, түүнд яг ижлээр ажиллана: executor-ийн нэг үгийг солиход л хангалттай. Харин process өөрийн үнэтэй бөгөөд бид тэр үнийг хэмжинэ.
Өмнөх хичээлийн хэвлэх газар руу буцъя. Таван ажилтан, гэвч газарт ганцхан хэвлэх машин бий. Захиалга ирэхэд ажилтан машин руу очиж хэвлэнэ. Хэрэв захиалга "үйлчлүүлэгчээс файл хүлээх" алхамтай бол ажилтан машинаас холдож утсаар ярина, тэр хооронд өөр ажилтан машиныг ашиглана. Хүлээлт ихтэй ажилд таван ажилтан маш үр ашигтай. Гэвч захиалга бүгд цэвэр хэвлэх бол таван ажилтан ганц машины өмнө дараалалд зогсоно: ажилтан нэмэх нь юу ч өгөхгүй. Ганц машин бол GIL.
Яагаад газар хоёр дахь машин авахгүй байна вэ? Учир нь газрын бүх бүртгэл, цаасны нөөц, хорны түвшин, захиалгын дугаар, тэр ганц машинтай уялдсан. Машин ганц байхад хоёр ажилтан хэзээ ч нэг зэрэг цаасны нөөцийг өөрчлөхгүй, бүртгэл хэзээ ч зөрөхгүй. Энэ энгийн байдал газрыг олон жил найдвартай ажиллуулсан. Хоёр машинтай болохын тулд бүх бүртгэлийг дахин зохион байгуулах хэрэгтэй.
Өөр нэг шийдэл бий: хоёр дахь салбар нээх. Тусдаа байр, тусдаа машин, тусдаа бүртгэл. Салбарууд бие биенийхээ машиныг хуваалцахгүй тул жинхэнэ зэрэг хэвлэнэ. Гэвч салбар нээх нь үнэтэй, салбар хооронд захиалга ба бэлэн бүтээгдэхүүнийг тээвэрлэх шаардлагатай, нэг салбарын бүртгэл нөгөөд харагдахгүй. Энэ бол process.
Python-д объект бүр өөрийг нь хэдэн газар хэрэглэж байгааг тоолдог: reference count, лавлагааны тоо. Хувьсагч объектыг заахад тоо нэмэгдэж, заахаа болиход хасагдана. Тоо тэг болоход объект хэрэггүй болсон тул санах ой чөлөөлөгдөнө. Хичээл 2.2-ын tracemalloc хэмжилтэд санах ой яг ийм механизмаар буцаж чөлөөлөгдөж байсан.
Энэ тоо өмнөх хичээлийн дэвтэртэй ижил асуудалтай. Хоёр thread нэг объектыг зэрэг хэрэглэж, тоог зэрэг өөрчилбөл нэг өөрчлөлт алдагдаж болно. Тоо хэт бага болвол объект хэрэглэгдэж байхад нь устгагдаж, програм унана. Хэт их болвол объект хэзээ ч устгагдахгүй. Python-ий объект бүр, тоо, string, list, функц бүр ийм тоотой, програм секундэд сая сая удаа тэдгээрийг өөрчилдөг.
Объект бүрд тусдаа Lock тавьж болох байсан, гэвч тэр нь бүх Python програмыг, ганц thread-тэйг ч, мэдэгдэхүйц удаашруулна. Python-ий бүтээгчид 1990-ээд онд өөр шийдэл сонгосон: тайлбарлагчид нэг том түгжээ. Python код гүйцэтгэхийн тулд thread түүнийг барих ёстой. Ингэснээр reference count болон тайлбарлагчийн бусад бүх дотоод бүтэц автоматаар хамгаалагдана. Ганц thread-тэй програм хурдан, C хэлээр бичсэн өргөтгөлүүд бичихэд хялбар. Тэр үед компьютерууд ихэвчлэн ганц цөмтэй байсан тул зэрэгцээ тооцоололд алдах зүйл бараг байгаагүй.
Хичээл 13.2-ын өгөгдөл энэ зураглалтай яг таарна. Thread sleep хийж эсвэл сүлжээ хүлээх үед GIL-ийг суллана: тиймээс хүлээлтүүд давхцсан. crunch-ийн давталт цэвэр Python код тул thread бүр GIL-ийг барьж байж л ажиллана: тиймээс таван thread ээлжилсэн, хэзээ ч зэрэг биш.
Олон цөмт процессорууд энгийн болсны дараа GIL Python-ий хамгийн их шүүмжлэгддэг онцлог болсон. Олон жилийн ажлын үр дүнд Python 3.13 нь GIL-гүй, free-threaded build-ийг туршилтын хэлбэрээр нэмсэн. Python 3.14-т тэр туршилт байхаа больж, албан ёсоор дэмжигдэх сонголт болсон. Python 3.15-д C өргөтгөлүүд нэг удаагийн бүтээлтээр ердийн ба free-threaded хоёр build-д хоёуланд нь ажиллах боломж нэмэгдсэн. Энэ нь сангуудын дэмжлэгийг хурдасгана.
Үүний практик утга гурван зүйлтэй. Нэгдүгээрт, үндсэн Python, python.org-оос анхдагчаар суулгадаг хувилбар, GIL-тэй хэвээр. Хэрэв та тусгайлан сонгоогүй бол таны Python GIL-тэй. Хоёрдугаарт, free-threaded build тусдаа суулгагдана, ихэвчлэн “3.14t” шиг “t” үсэгтэй нэрээр. Тэр дээр Хичээл 13.1-ийн тооцооллын thread-үүд жинхэнэ зэрэг ажиллаж, цөмийн тоогоор хурдасч болно. Гуравдугаарт, үнэ бий: албан ёсны баримт бичгийн дагуу free-threaded build ганц thread-тэй кодыг бага зэрэг удаан гүйцэтгэдэг, илүү их санах ой хэрэглэдэг бөгөөд GIL-тэй build-д зориулж бүтээсэн C өргөтгөлийг ачаалбал GIL-ийг дахин асааж болно. Сангуудын дэмжлэг хурдан өсч байгаа ч бүрэн биш.
Таны Python аль нь вэ? Шалгая:
python
import sys
print(sys.version)
print("GIL идэвхтэй:", sys._is_gil_enabled())Ердийн Python 3.14 дээр:
3.14.0 (main, ...) [MSC v.1944 64 bit (AMD64)]
GIL идэвхтэй: Truesys._is_gil_enabled() Python 3.13-аас хойш бий. Нэрийн эхэн дэх доогуур зураас нь Python-ий "дотоод" функц гэсэн тэмдэглэгээ: ирээдүйд өөрчлөгдөж болно, гэвч шалгалтад найдвартай. Free-threaded build дээр False буцаана.
Энэ сургалт ердийн, GIL-тэй Python дээр ажиллахаар бичигдсэн. Free-threaded Python ирээдүй рүү чиглэсэн сонирхолтой зам бөгөөд хэрэв та түүнийг суулгавал Хичээл 13.1-ийн хэмжилтийг дахин ажиллуулж, өөрийн нүдээр харахыг санал болгоно. Харин өнөөдөр, ямар ч Python дээр, ямар ч сангаар CPU-bound ажлыг хурдасгах найдвартай арга бол process.
Хичээл 13.1-ийн crunch-ийг thread ба process-оор харьцуулъя. compare_pools.py файл:
python
import time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
def crunch(n=5_000_000):
total = 0
for i in range(n):
total += i * i
return total
def run(executor_class, tasks):
start = time.perf_counter()
with executor_class(max_workers=tasks) as executor:
results = list(executor.map(crunch, [5_000_000] * tasks))
return time.perf_counter() - start, results
if __name__ == "__main__":
start = time.perf_counter()
expected = [crunch() for _ in range(4)]
print(f"Дараалсан: {time.perf_counter() - start:.2f} с")
elapsed, results = run(ThreadPoolExecutor, 4)
print(f"Thread x4: {elapsed:.2f} с")
elapsed, results = run(ProcessPoolExecutor, 4)
print(f"Process x4: {elapsed:.2f} с")
print("Үр дүн ижил:", results == expected)Thread хувилбараас ялгаатай нь ердөө нэг үг: ThreadPoolExecutor-ийн оронд ProcessPoolExecutor. map, with, үр дүнгийн дараалал бүгд ижил. Гэвч нэг шинэ шаардлага бий: if __name__ == "__main__": хамгаалалт энд заавал. Түгээмэл алдаанууд хэсэгт түүнгүйгээр юу болохыг харна. Найман логик цөмтэй, ердийн Python 3.14 бүхий нэг компьютер дээр:
Дараалсан: 1.31 с
Thread x4: 1.33 с
Process x4: 0.39 с
Үр дүн ижил: TrueThread юу ч өгөөгүй, бидний хүлээсэнчлэн. Process гурав дахин илүү хурдан: дөрвөн тооцоолол дөрвөн цөм дээр жинхэнэ зэрэг явсан. Яг дөрөв дахин биш, учир нь process-уудыг эхлүүлэх зардал бий. Тэр зардлыг удахгүй хэмжинэ.
ProcessPoolExecutor юу хийсэн бэ? Тэр дөрвөн тусдаа Python тайлбарлагч эхлүүлсэн: зүйрлэлийн дөрвөн салбар. Тус бүр өөрийн санах ой, өөрийн GIL-тэй. Тэд бие биенээ хүлээх шаардлагагүй, үйлдлийн систем тэднийг өөр өөр цөмд хуваарилна. Task Manager-ийг нээж, process хувилбарыг ажиллуулж байхад хэд хэдэн “python.exe” гарч ирэхийг харж болно.
Салбар нээх үнэтэй, зүйрлэлд хэлсэнчлэн. Process-ийн гурван үнийг хэмжиж, ойлгоё.
Эхлүүлэх үнэ. Process бүр шинэ тайлбарлагч эхлүүлж, таны модулийг дахин import хийнэ. Маш жижиг ажилд энэ зардал ажлаасаа олон дахин том:
python
def tiny(x):
return x * 2
if __name__ == "__main__":
numbers = list(range(1_000))
start = time.perf_counter()
[tiny(x) for x in numbers]
print(f"Дараалсан: {time.perf_counter() - start:.4f} с")
start = time.perf_counter()
with ProcessPoolExecutor() as executor:
list(executor.map(tiny, numbers))
print(f"Process: {time.perf_counter() - start:.4f} с")Дараалсан: 0.0001 с
Process: 0.2847 сМянган жижиг ажил дараалсан хувилбарт микросекундууд зарцуулсан, process-тэй хувилбарт 0.28 секунд: хэдэн мянга дахин удаан. Process-ийг эхлүүлэх, ажил бүрийг илгээх, үр дүнг буцаах нь ажлаасаа хамаагүй үнэтэй байсан.
Тээвэрлэх үнэ. Process-ууд санах ой хуваалцдаггүй тул аргумент ба үр дүн бүр нэг process-оос нөгөө рүү хуулагдана. Python тэдгээрийг pickle буюу байт болгон хувиргаж илгээдэг. Том жагсаалт, том dict дамжуулбал хуулах ажил тооцооллын хэмнэлтийг идэж болно. Тиймээс process-д бага өгөгдөл өгч, бага өгөгдөл буцаадаг, гэхдээ дотроо их тооцоолол хийдэг ажил хамгийн тохиромжтой. crunch яг ийм: нэг тоо орно, нэг тоо гарна, хооронд нь таван сая үйлдэл.
Жижиг ажлуудыг багцлах арга бий: executor.map-д chunksize өгөх. executor.map(tiny, numbers, chunksize=250) нь мянган ажлыг 250-аар багцалж, дөрвөн илгээлт болгоно. Энэ тээвэрлэлтийн давтамжийг эрс багасгана, гэвч эхлүүлэх үнийг арилгахгүй. Process-ийн хувьд асуулт хэвээр: ажил эхлүүлэх зардлыг нөхөх хэмжээний том уу?
Тусгаарлалтын үнэ. Process-ууд санах ой хуваалцдаггүй гэдэг нь өмнөх хичээлийн race condition энд үүсэхгүй гэсэн үг. Гэвч эсрэг тал бий:
python
counter = 0
def add_one(_):
global counter
counter += 1
return counter
if __name__ == "__main__":
with ProcessPoolExecutor(max_workers=4) as executor:
returned = list(executor.map(add_one, range(8)))
print("Буцаасан утгууд:", returned)
print("Үндсэн process-ийн counter:", counter)Буцаасан утгууд: [1, 2, 1, 3, 2, 1, 1, 2]
Үндсэн process-ийн counter: 0Process бүр өөрийн counter-ийн хуулбартай. Ажил бүр өөрийн process-ийн хуулбарыг нэмсэн, буцаасан утгууд санамсаргүй мэт: аль ажил аль process-д очсоноос хамаарна. Үндсэн process-ийн counter огт хөндөгдөөгүй: тэг. Салбарын бүртгэл төв оффист харагдахгүй. Таны буцаасан утгууд нь өөрөөр хэлбэл хуваалцахгүй байх загвар, Хичээл 13.2-ын хамгийн сайн шийдэл, process-д сонголт биш, цорын ганц зөв загвар.
Буцаасан утгууд таны компьютер дээр өөр байна: энэ бол гол санаа.
Энэ бүлгийн хэмжилтүүдийг нэгтгэе. Эхлээд Хичээл 13.1-ийн хоёр цагийн хэмжилтээр ажлын төрлийг тодорхойл.
I/O-bound ажилд, хүлээлт давамгайлах: thread, ThreadPoolExecutor. Хөнгөн, эхлүүлэх зардал бага, санах ой хуваалцах тул өгөгдөл дамжуулах шаардлагагүй. Хуваалцсан төлөвт анхаар, боломжтой бол утга буцаа. Дараагийн бүлгийн asyncio энэ ангилалд өөр, зарим тохиолдолд илүү сайн хариулт өгнө.
CPU-bound ажилд, тооцоолол давамгайлах: process, ProcessPoolExecutor. Ажил бүр эхлүүлэх зардлыг нөхөх хэмжээний том, аргумент ба үр дүн бага байх ёстой. if __name__ == "__main__": заавал. Free-threaded Python ирээдүйд энэ ангилалд thread-ийг ч хэрэглэх боломж нээж байна, гэвч энэ нь сонголттой, сангуудын дэмжлэгээс хамаарсан хэвээр.
Аль нь ч биш: ажил хангалттай хурдан, эсвэл маш жижиг. Дараалсан код хамгийн энгийн, хамгийн хурдан ч байж болно, мянган жижиг ажлын хэмжилт үүнийг харуулсан.
Concurrent.futures-ийн хамгийн ухаалаг шийдвэр нь хоёр executor-ийн ижил интерфейс: хэмжилт таны ажлын төрлийг буруу тодорхойлсон гэдгийг харуулбал нэг үг сольж, дахин хэмжинэ.
Хүсвэл compare_pools.py-ийн ажлын тоог өөрийн компьютерийн цөмийн тоотой тааруулж, os.cpu_count(), 2, 4, 8 гэх мэтээр process-ийн хугацаа хэрхэн буурч байгааг хэмжиж үзээрэй: аль цэгээс нэмэх нь ашиггүй болох вэ? Сонирхвол Хичээл 3.3-ын memo-гүй fib(30)-ийг дөрвөн удаа process-оор зэрэг тооцоолж, дараалсан хувилбартай харьцуулж болно. Мөн хэрэв free-threaded Python суулгах сонирхол байвал, Windows дээр python.org-ын installer-ийн нэмэлт сонголтоор суулгаж, Хичээл 13.1-ийн thread хэмжилтийг тэр дээр дахин ажиллуулах нь энэ хичээлийн хамгийн сонирхолтой туршилт. Эдгээр бүгд санал бөгөөд дараагийн хичээлүүд үүнийг хийсэн гэж үзэхгүй.
RuntimeError: An attempt has been made to start a new process before the current process has finished its bootstrapping phase. "Одоогийн process эхлэлийн шатаа дуусгахаас өмнө шинэ process эхлүүлэх оролдлого хийгдлээ" гэсэн утгатай, мессеж if __name__ == '__main__': хэрэглэхийг зөвлөсөн урт тайлбартай үргэлжилнэ. Windows ба macOS дээр шинэ process таны модулийг эхнээс нь import хийдэг. Хэрэв executor үүсгэх код хамгаалалтгүй модулийн түвшинд байвал шинэ process бүр өөрөө дахин executor үүсгэх гэж оролдоно. Python үүнийг илрүүлж зогсооно. Шийдэл: executor-тэй бүх кодыг if __name__ == "__main__": дотор байрлуул. Linux дээр энэ алдаа заримдаа гардаггүй тул тэнд бичсэн код Windows дээр унаж болно: хамгаалалтыг үргэлж бич.
PicklingError: Can’t pickle <function <lambda> ...>. “lambda функцийг pickle хийх боломжгүй” гэсэн утгатай. Мессежийн яг хэлбэр Python-ий хувилбараас хамаарч бага зэрэг өөр. Хичээл 13.1-ийн lambda: fake_download("хуудас") загварыг process-д хэрэглэхэд гарна. Process руу функцийг нэрээр нь илгээдэг: шинэ process модулийг import хийгээд тэр нэрээр функцийг олно. lambda ба функц доторх функц, Бүлэг 4-ийн closure, модулийн түвшинд нэргүй тул олдохгүй. Шийдэл: process-д илгээх функцийг модулийн түвшинд def-ээр тодорхойл. Аргумент хэрэгтэй бол executor.map-д дамжуул, эсвэл Хичээл 3.3-ын functools.partial ашигла: partial объект pickle хийгдэх боломжтой.
Process-ийн хувьсагчийн өөрчлөлт алга болох. Мессежгүй, тусгаарлалтын хэсэгт харсан. Ажил global хувьсагч, class attribute эсвэл аргументаар ирсэн list-ийг өөрчилбөл тэр өөрчлөлт зөвхөн тухайн process-ийн хуулбарт үлдэнэ. Үр дүнг үргэлж буцаах утгаар дамжуул.
Жижиг ажлыг process-д өгөх. Мессежгүй, хэмжилтээр харсан: мянган жижиг ажил дараалсан хувилбараас мянга дахин удаан. Ажил бүрийг perf_counter-оор хэмжиж, хэдэн миллисекундээс бага бол process ашиггүй, chunksize ч хангалтгүй байж магадгүй.
Free-threaded build-ийг хэмжилтгүй сонгох. Мессежгүй. GIL-гүй Python CPU-bound thread-ийг хурдасгадаг, харин ганц thread-тэй кодыг бага зэрэг удаашруулдаг бөгөөд таны хэрэглэдэг сан дэмжихгүй бол GIL буцаж асч болно. Бүлэг 12-ын хууль энд ч: өөрийн програм дээр хоёр build-ийг хэмжиж байж шийд.
GIL бол тайлбарлагчийн ганц түгжээ: нэг агшинд нэг thread л Python код гүйцэтгэнэ. Тэр reference count болон тайлбарлагчийн бусад дотоод бүтцийг хамгаалж, ганц thread-тэй кодыг хурдан, C өргөтгөлийг энгийн байлгадаг. Хүлээж буй thread GIL-ийг суллана, тиймээс thread I/O-bound ажилд тусалдаг, CPU-bound ажилд тусалдаггүй. Python 3.13 GIL-гүй free-threaded build-ийг туршилтаар нэмсэн, 3.14-т тэр албан ёсоор дэмжигдэх сонголт болсон, гэвч үндсэн Python GIL-тэй хэвээр. sys._is_gil_enabled() таны Python-ийг шалгана. ProcessPoolExecutor тусдаа тайлбарлагчууд эхлүүлж, тус бүр өөрийн GIL-тэй тул CPU-bound ажлыг цөмүүд дээр жинхэнэ зэрэг гүйцэтгэнэ. Интерфейс нь ThreadPoolExecutor-тэй ижил. Process гурван үнэтэй: эхлүүлэх зардал, аргумент ба үр дүнг pickle хийж хуулах зардал, санах ой хуваалцдаггүй тусгаарлалт. Тиймээс process том, бага өгөгдөлтэй ажилд, үр дүнгээ буцаах утгаар дамжуулдаг загварт тохиромжтой. if __name__ == "__main__": хамгаалалт заавал, илгээх функц модулийн түвшинд def-ээр тодорхойлогдсон байх ёстой. Хэрэгслийн сонголт хоёр цагийн хэмжилтээс эхэлнэ.
Ингээд Бүлэг 13 дууслаа. Thread I/O-bound ажлыг хурдасгадаг, гэвч thread бүр санах ой эзэлдэг бөгөөд мянга мянган зэрэг холболт, жишээлбэл чат сервер эсвэл мянган API хүсэлт, thread-ээр удирдахад хүнд болдог. Мөн race condition-ий аюул үргэлж дагалдана. Дараагийн бүлэг өөр арга руу шилжинэ: asyncio. Тэнд ганц thread, ганц тогооч, хэзээ хүлээхээ кодонд өөрөө тодорхой зарладаг. Гайхалтай нь Бүлэг 2-т сурсан generator-ийн "зогсоох товч", yield, asyncio-гийн үндэс болсон санаатай маш ойр. Тэр холбоог бид дараагийн хичээлд харна.
Бүртгэлтэй болсноор энэ сургалтын бүх хичээлд хандах эрх авна.