Technology

Python Multiprocessing vs Threading vs Asyncio ต่างกันยังไง ?

By Arnon Puitrakul - 28 พฤศจิกายน 2022

Python Multiprocessing vs Threading vs Asyncio ต่างกันยังไง ?

เวลาเราเขียนโปรแกรมทั่ว ๆ ไป เราอาจจะไม่มีปัญหาเท่าไหร่ เพราะ ปริมาณการทำงานมันไม่ได้เยอะมากเท่าไหร่ เราจบปัญหาด้วยการใช้ CPU แค่ส่วนเดียว หรือทำลงไปเรื่อย ๆ ได้ แต่ถ้าข้อมูลของเรามันเยอะมาก ๆ หรือ มีการประมวลผลที่ซับซ้อนมาก ๆ การทำแบบนั้นมันเสียเวลามาก ทำให้เราจะต้องมีวิธีการบางอย่างที่ทำให้เราสามารถใช้ประโยชน์จาก CPU สมัยใหม่ได้มากขึ้น ใน Python ก็มีเครื่องมือมาให้เราใช้งานอย่าง Multiprocessing, Threading และ Asyncio เป็นอย่างไร เรามาดูกัน

A Brief Introduction to GIL

Python Global Interpreter Lock แบบเข้าใจง่าย?
Global Interpreter Lock (GIL) ใน Python ถือเป็นเรื่องที่เป็นศัตรูกับการทำ Multithread ใน Python เป็นอย่างมาก วันนี้เรามาทำความรู้จักกับมันกัน

ใน Python เอง มันเป็นภาษาที่มีการ Implement กลไกบางอย่างที่เราเรียกว่า Global Interpreter Lock (GIL) มันเป็นกลไกที่ไม่อนุญาติให้ตัว Python Interpreter ทำงานพร้อม ๆ กันได้ ส่งผลให้มีทั้งข้อดี และ ข้อเสียตามมา

ข้อดีคือ เราตัดเรื่องของการจัดการ Memory ในแง่ของ Data Consistency เช่นพวก การใช้งาน Mutex Lock หรือ Semaphore เพราะ GIL การันตีให้เราแล้วว่า ข้อมูลจะไม่พลาดแน่นอน ด้วยการที่มีแค่ Thread เดียวของ Python เท่านั้นที่สามารถเข้าถึงข้อมูลได้พร้อม ๆ กัน

แต่ปัญหามันก็มาแน่นอน เพราะเมื่อเราต้องการจะเขียนโปรแกรมแบบ Parallel เราทำงานหลาย ๆ อย่างพร้อมกัน แต่ GIL ไม่อนุญาติให้เราทำงานด้วยซ้ำ ทำให้ ถึงเราเขียนโปรแกรมแบบ Multi-Threading เราก็ทำงานได้ไม่เกิน 1 Thread พร้อม ๆ กันแน่นอน หรือถ้าเป็น CPU Utilisation บน UNIX ก็คือไม่เกิน 100% แน่นอน ต่างจากภาษาอื่น ๆ อย่าง C หรือ C++ ที่ไม่ได้ห้ามอะไรเลย ให้เราจอง Memory อย่าง malloc() ยังได้เลย

Threading

หนึ่งในวิธีการที่ทำให้เราสามารถ Parallel การทำงานบน Python ได้ ตัวที่พื้นฐานที่สุดน่าจะเป็นการทำ Threading เป็นวิธีการที่เราทำกันมาอย่างยาวนานแล้วละ

สิ่งที่เราทำคือ เวลาเรารัน Python ขึ้นมา เราจะได้ Process ขึ้นมา ซึ่งใน Process เอง มันก็สามารถประกอบได้ด้วยหลาย ๆ Thread แต่ทั่ว ๆ ไป ถ้าเราไม่ได้แบ่ง Thread อะไร มันจะมีการสร้าง Thread ขึ้นมาอยู่แล้ว เราเรียกว่า Main Thread ฟิล ๆ เป็น Conductor ให้กับโปรแกรมทั้งหมด

ถ้าเราแบ่ง Thread ออกไป มันก็เหมือนกับเรา Spawn คนทำงานเพิ่มขึ้นมาอีกหนึ่งคน พวกนี้เราเรียกว่า Worker Thread โดยมี Main Thread เป็นตัวคุมว่า ต้องทำอันนี้นะ นั่นแปลว่า ถ้าเรามีคนคุมอยู่แล้ว ทำให้ข้อดีของวิธีการทำแบบนี้คือ เราสามารถ เริ่ม หยุด หรือ หยุดชั่วคราวได้แบบที่เราต้องการเลย

ปัญหาที่เราเอา Threading มาแก้คือ พวก GUI ทั้งหลาย เราอาจจะมี หลาย ๆ Element บนหน้าจอของเรา อาจจะมี Textbox ตัวนึงกำลังนับอะไรบางอย่างอยู่ หรือ เรากดให้มัน Process ข้อมูลบางอย่างอยู่ ถ้าเราไม่มี Thread เลย นั่นแปลว่า ขณะที่โปรแกรมกำลังทำงานอยู่ พวก GUI ของโปรแกรมจะค้างเลย หรือก็คือ เรา Update หรือ Interact อะไรไม่ได้เลย เป็นเรื่องที่ตลกใช่มะ เพราะ User ก็จะเข้าใจว่า อ่อ โปรแกรมมันค้างไปแล้ว

ดังนั้นวิธีแก้ปัญหาคือ เราแยกแต่ละส่วนออกมาเป็น Thread ซะ การที่เราคลิกปุ่ม หรือ Interact อาจจะเป็นการ Trigger Interrupt เพื่อให้หน้าจอมัน Render Effect และทำงานตามที่เขียนเอาไว้ ในขณะที่สิ่งที่ทำงานอยู่อาจจะหยุดชั่วคราว พอเสร็จ มันก็ทำงานที่ค้างไว้ต่อได้นั่นเอง

ความพีคของ Python อยู่ที่ GIL นี่แหละ เพราะ Python อนุญาติให้แค่ 1 Thread ทำงานได้เท่านั้น เราไม่สามารถให้ Thread อื่น ๆ ทำงาน และ เข้าถึงหน่วยความจำได้ ทำให้อย่างที่เราบอกคือ ไม่ว่าอย่างไร เราจะแบ่งกี่ Thread เราก็จะใช้ CPU เกิน 100% หรือหลาย ๆ Core พร้อม ๆ กันได้นั่นเอง จริง ๆ พวก OS สมัยก่อนนานมาก ๆ มาละ ตอนนั้น CPU มันยังมี Core เดียว และยังไม่รองรับ Hyperthreading แต่ User ต้องการ Multitasking แต่อ้าว Resource เรามีแค่นี้

สิ่งที่เราทำก็คือ การทำ Multitasking แบบแกล้ง ๆ เราก็แบ่งแต่ละอย่างออกมาเป็น Thread นี่แหละ พอเราสลับหน้าโปรแกรม OS มันก็จะไป Interrupt Thread แล้วเอาอีก Thread รันต่อไป ทำให้เวลาเราใช้งานจริง ๆ มันเลยเหมือนกับว่าาาา OS มัน Multitasking ได้จริง ๆ นั่นเอง แต่ CPU สมัยใหม่ เรามีทั้ง Multi-Core Scheme และ Hypertheading กันแล้ว ทำให้เราไม่ต้องแกล้ง ๆ แล้ว

ถามว่า แล้ว Threading มันดีกับปัญหาแบบไหน อย่างที่บอกไป ข้อดีของมันคือ การสามารถหยุดการทำงาน แล้วสั่งให้มันกลับมาทำงานต่อได้ เลยทำให้เหมาะกับพวกปัญหาที่เราจะต้องมี การรอ เช่น อาจจะต้องรอ Network หรือ Storage เราก็สั่งเรียกไป ระหว่างที่มันโหลดเข้ามาอยู่ เราก็สั่ง Suspend Thread ก่อน แล้วเอาอีก Thread ขึ้น พอข้อมูลมา เราก็ Resume Thread แล้ว Suspend Thread ก่อนหน้าไป ก็ทำให้เราสามารถ Utilise CPU เราได้อย่างมีประสิทธิภาพสูงขึ้น

Asyncio

Coroutine บน Python : ปูนและอิฐสำหรับ Asynchronous Programming
วันนี้เราจะพาไปรู้จัก Foundation Concept ที่เราใช้ในการเขียนโปรแกรมแบบ Asynchronous อย่าง Coroutine กันว่า ในภาษา Python เราจะ Implement มันได้อย่างไร

จากปัญหาเดิมใน Threading คือ เมื่อเรา Request ข้อมูลไป แล้วเรารอข้อมูลวิ่งกลับมา ระหว่างนั้นเรา Suspend Thread ไปก่อนแล้ว พอข้อมูลถึง เราก็ Resume มันกลับขึ้นมา ถ้าเรามี Task แบบนี้แค่อันเดียว ทุกอย่างง่ายมาก ๆ แต่ถ้าเรามีหลายอันละ และข้อมูลมีขนาดไม่เท่ากัน นั่นแปลว่า เวลาในการรอมันจะต้องไม่เท่ากันใช่มะ

ถ้าเป็น Threading เราฟิคไว้แล้วว่า เราจะรออะไรก่อนหลัง เราแค่ให้โอกาส Python มันได้เรียกสั่งไป แต่เวลาเราเอาข้อมูล เราไม่รู้หรอกว่า อันไหนจะมาก่อน เราก็เลยเรียกตามลำดับไป หรือ อาจจะมีกลยุทธ์ในการเรียงอะไรก็ว่ากันไป ซึ่งมันก็แอบเสียเวลาเหมือนกัน

งั้นเราเปลี่ยนวิธีใหม่ดีกว่า งั้นถ้าใครมาก่อน ใครเสร็จก่อน ก็เอากลับไปก่อนเลย แทนที่จะต้องมานั่งหาวิธีว่า เราจะเรียงจากอะไร บางครั้งมันก็ไม่แม่น 100% หรอก นี่แหละ คือ Asyncio

แต่สิ่งที่เกิดขึ้นจริง ๆ มันไม่ได้เป็นการทำงานพร้อม ๆ กันแล้วรอใครมาก่อนก็ไป แต่มันจะแบ่งงานออกมาเป็นสิ่งที่เรียกว่า Coroutine โดยที่ใน Coroutine เองก็สามารถเรียก Coroutine อื่น ๆ ซ้อน ๆ ลงไปก็ได้เหมือนกัน

แล้วถามว่า ถ้าเรามีหลาย ๆ Coroutine แล้วมันจะรู้ได้ยังไงว่า มันทำเสร็จแล้ว แล้วโยนกลับไป มันเลยต้องมีอีกส่วนหนึ่งคือ Event Loop จริง ๆ มันก็เหมือนกับ Loop ทั่ว ๆ ไปนี่แหละ คอยเช็คว่า ใครทำเสร็จแล้ว ใครหยุด ใครต้องวิ่งต่อ

ถ้าอ่าน ๆ ดู อาจจะเอ๊ะว่า แล้วมันต่างจาก Thread อย่างไร คล้ายกันมากเลยนะ สิ่งที่ต่างกันคือใน Thread เราจะต้องรันจบ หมายความว่า เราจะต้องรันงานแรกให้เสร็จก่อน ถ้าเราไม่ Suspend มันอะนะ แล้วงานอื่นถึงจะทำได้ เช่น ถ้าเราต้องมีการรอหลาย ๆ รอบ มันก็ต้องรอและทำงานไปให้จบ Thread อื่น ๆ จะเข้ามาแทรกไม่ได้ แต่ใน Asyncio มันสามารถทำแบบนั้นได้ ถ้ามีรอเมื่อไหร่ มันสามารถแทรก Coroutine รันไปเรื่อย ๆ จนกว่าจะรอรอบหน้า แล้วเอาอันเดิมเสียบกลับเข้าไปได้ ทำให้มันใช้งาน CPU ได้มีประสิทธิภาพกว่าเดิมมาก ๆ เหมาะกับงานที่เป็นพวก IO-Bound มาก ๆ

Multiprocessing

วิธีการแก้ปัญหา 2 แบบก่อนหน้า เราเรียกว่า มันทำงานแบบมี Concurrent เฉย ๆ เหมือนกับเราต่อแถวขึ้นสะพาน เราอาจจะมี 2 แถว แต่ทางขึ้นมาเลนเดียว เราก็ต้องพลัด ๆ กันขึ้นไป ไม่สามารถขึ้นพร้อม ๆ กันได้ หรือในกรณีเราเปรียบกับ เราไม่สามารถทำงานหลาย ๆ อย่างบน Python พร้อมกันได้จริง ๆ

เหตุเป็นเพราะ GIL ที่ทำให้เราไม่สามารถรันแบบ Parallel จริง ๆ ได้เลย แต่ ๆๆๆๆ GIL มันบอกว่า มันไม่ให้ใน 1 Process รันหลาย ๆ Thread พร้อม ๆ กันแค่นั้นนิ แล้วถ้าเราเปลี่ยนใหม่ ๆ เราบอกว่า เรารันหลาย ๆ Process เลยละ ดูเหมือนกวนตีนนะ แต่มันทำแบบนั้นจริง ๆ

หลักการแบบนี้ไม่ได้เป็นเรื่องใหม่อะไร เพราะใน C เราก็ทำแบบนั้นกันมานานแล้ว ถ้าคุ้น ๆ ก็จะได้ยินว่า เห้ย ๆ เรา Fork Process ออกมานั่นแหละ แต่อันนั้น มันจะ Manual ไปหน่อย ไม่สิ เยอะเลยเห้ย !

วิธีนี้ใน Python มีมาให้เราเลยเรียกว่า Multiprocessing มันอนุญาติให้เราทำการ Fork Process ออกมาเพื่อรันอะไรบางอย่างได้ นั่นแปลว่า เราสามารถรันแบบ Parallel จริง ๆ ได้หมดเลย แล้วพอมันแบ่งออกมาเป็น Process เราทำอะไรได้พีคกว่านั้นอีก ในแต่ละ Process เราก็สามารถเอาพวก Threading และ Asyncio ยัดใส่เข้าไปได้อีก เพื่อให้เราสามารถจัดการ Concurrency ได้ละเอียดขึ้น

แต่ ๆๆๆ เมื่อการทำงานมันแยก Process กันนั่นหมายความว่า แต่ละ Child Process ที่ทำการ Fork ออกไป เราไม่สามารถให้มันคุยกันได้เลยนะ แต่ละ Process แยกออกจากกันโดยสิ้นเชิง ถ้าเราเอาไปจัดการข้อมูล เราก็ให้มันจัดการภายใน Process แล้วให้ Main Process รอจนทุก Child Process ทำงานเสร็จแล้วรวมผลอีกที ลักษณะการทำงานจะเป็นแบบนั้น

ดังนั้น การทำ Multiprocess จะเหมาะกับงานที่เป็น CPU-Bound มาก ๆ ทุกอย่างแยกกันหมด แต่เราสามารถทำหลาย ๆ งานได้พร้อม ๆ กันจริง ๆ งานที่เราจะใช้เยอะ ๆ กับ Multiprocessing คือการทำพวก ETL Process เรารู้อยู่แล้วว่า เราจะต้องทำอะไร เช็คอะไรบ้าง แค่เรามีข้อมูลเยอะมาก ๆ เท่านั้นเอง เราก็แค่แบ่งข้อมูลแยกออกไปตาม Process แล้วก็ทำงานก็เรียบร้อย เร็วขึ้นแบบสุด ๆ

สรุป

ทั้ง 3 วิธีการที่เราเล่าไป มันเป็นวิธีที่เราพยายามที่จะใช้งาน CPU ให้มีประสิทธิภาพสูงสุด โดยลดเวลาในการรอลง หรือจากที่เราเห็นใน Utilisation Graph คือ ทำให้ CPU วิ่งเต็ม 100% ตลอดเวลานั่นเอง ถ้าเราเปรียบเทียบทั้ง 3 วิธีง่าย ๆ เหมือนกับเวลาเรายืนซื้อตู้เต่าบินอะ

ถ้าเป็นแบบเดิม ๆ Synchronous เลยคือ เราก็ต่อคิวกัน คนนึงสั่งแล้วต้องยืนรอหน้าตู้ให้เสร็จ อีกคนมาต่อไปเรื่อย ๆ

ถ้าเป็น Threading คือ เราสามารถสั่งแล้วเดินออกมารอแถว ๆ ตู้เครื่องมันก็ทำไป เครื่องก็ทำเรียง ๆ กันออกมาเรื่อย ๆ

ถ้าเป็น Asyncio คือ เราก็สั่งแล้วเดินออกมาเหมือน Threading เลย แต่ เครื่องมันสามารถทำหลาย ๆ เมนูได้พร้อมกัน แต่ อุปกรณ์แต่ละชิ้นไม่ได้ทำงานพร้อม ๆ กันซะทีเดียว เช่น อันนี้ต้องปั่นเครื่องปั่นว่างมันก็ปั่น อีกเมนูต้องทำน้ำแข็งมันก็รอปั่นเสร็จ ก็มาทำน้ำแข็ง แล้วทำแบบนี้ไปเรื่อย ๆ

และ Multiprocessing เหมือนกับ เรามีหลาย ๆ เครื่อง แล้วเราก็เอาคนต่อ ๆ เข้าไปเรื่อย ๆ นั่นเอง

Read Next...

ลองกันอีกสักตั้ง iPad Pro ใช้แทนคอมพิวเตอร์ได้มั้ย

ลองกันอีกสักตั้ง iPad Pro ใช้แทนคอมพิวเตอร์ได้มั้ย

เมื่อ 3 ปีก่อน เรามีความพยายามที่จะใช้ iPad Pro เครื่องเดิมแทนคอมพิวเตอร์ ไหน ๆ ตอนนี้เราเปลี่ยน iPad Pro ใหม่แล้ว เราจะมาลองกันอีกสักตั้งว่า เมื่อเวลาผ่านไป มันใช้งานจริงได้มากขึ้นหรือไม่...

ทำไม iPad ยังเป็น iPad ไม่เป็น Mac

ทำไม iPad ยังเป็น iPad ไม่เป็น Mac

ตั้งแต่ iPad Pro M4 และ iPad Air M2 เปิดตัวและเริ่มจำหน่ายออกไป Reviewer หลายคนเริ่มมองเห็นแล้วว่า ปัญหาจริง ๆ ของ iPad ในรอบหลายปีที่ผ่านมา ไม่ได้เกิดจาก iPad แต่เกิดจาก iPadOS บางเจ้าบอกว่า อยากให้เอา macOS มาใส่ด้วยซ้ำ มันยังไงกันนะ วันนี้เราจะมาเล่าประเด็นและความเห็นจากเราให้ให้อ่านกัน...

Microinverter ต่างจาก String Inverter อย่างไร เลือกแบบไหนดีกว่ากัน

Microinverter ต่างจาก String Inverter อย่างไร เลือกแบบไหนดีกว่ากัน

หลังจากเราเขียนเรื่อง Solar Cell ไปมีคนถามเข้ามาอยู่ว่า ถ้าจะเลือกติดตั้ง Solar ระหว่างการใช้ระบบ String Inverter กับ Microinverter เราจะเลือกตัวไหนดี วันนี้เราจะมาเล่าเปรียบเทียบให้อ่านกันว่าแบบไหน น่าจะเหมาะกับใคร...

ทำไมภาษา Programming สมัยใหม่ ถึงไม่มี Pointer Concept

ทำไมภาษา Programming สมัยใหม่ ถึงไม่มี Pointer Concept

ทำไมภาษาบางตัวอย่างภาษา C มี Pointer ในขณะที่ภาษาใหม่ ๆ หลายตัว ไม่มี ทำไมการ Implement Concept หรือเครื่องมือเหล่านี้ถึงไม่ได้รับความนิยม วันนี้เราจะมาเล่าข้อดีข้อเสียของ Feature นี้ในภาษา Programming กัน...