By Arnon Puitrakul - 28 พฤศจิกายน 2022
เวลาเราเขียนโปรแกรมทั่ว ๆ ไป เราอาจจะไม่มีปัญหาเท่าไหร่ เพราะ ปริมาณการทำงานมันไม่ได้เยอะมากเท่าไหร่ เราจบปัญหาด้วยการใช้ CPU แค่ส่วนเดียว หรือทำลงไปเรื่อย ๆ ได้ แต่ถ้าข้อมูลของเรามันเยอะมาก ๆ หรือ มีการประมวลผลที่ซับซ้อนมาก ๆ การทำแบบนั้นมันเสียเวลามาก ทำให้เราจะต้องมีวิธีการบางอย่างที่ทำให้เราสามารถใช้ประโยชน์จาก CPU สมัยใหม่ได้มากขึ้น ใน Python ก็มีเครื่องมือมาให้เราใช้งานอย่าง Multiprocessing, Threading และ Asyncio เป็นอย่างไร เรามาดูกัน
ใน 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() ยังได้เลย
หนึ่งในวิธีการที่ทำให้เราสามารถ 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 เราได้อย่างมีประสิทธิภาพสูงขึ้น
จากปัญหาเดิมใน 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 มาก ๆ
วิธีการแก้ปัญหา 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 เหมือนกับ เรามีหลาย ๆ เครื่อง แล้วเราก็เอาคนต่อ ๆ เข้าไปเรื่อย ๆ นั่นเอง
หลังจากดูงาน Google I/O 2024 ที่ผ่านมา เรามาสะดุดเรื่องของการใส่ Watermark ลงไปใน Content ที่ Generate จาก AI วันนี้เราจะมาเล่าให้อ่านกันว่า วิธีการทำ Watermark ใน Content ทำอย่างไร...
ก่อนหน้านี้เราทำ Content เล่าความแตกต่างระหว่าง CPU, GPU และ NPU ทำให้เราเกิดคำถามขึ้นมาว่า เอาเข้าจริง เราจำเป็นต้องมี NPU อยู่ในตลาดจริง ๆ รึเปล่า หรือมันอาจจะเป็นแค่ Hardware ตัวนึงที่เข้ามาแล้วก็จากไปเท่านั้น วันนี้เราจะมาเล่าให้อ่านกัน...
บทความนี้ เราเขียนสำหรับมือใหม่ หรือคนที่ไม่ได้เรียนด้านนี้แต่อยากรู้ละกัน สำหรับวันนี้เรามาพูดถึงคำที่ถ้าเราทำงานกับพวก Developer เขาคุยกันบ่อย ๆ ใช้งานกันเยอะ ๆ อย่าง Database กันว่า มันคืออะไร ทำไมเราต้องใช้ และ เราจะมีตัวเลือกอะไรในการใช้งานบ้าง...
หากใครที่อายุใกล้ ๆ 30 ต้องเคยผ่านประสบการณ์โลกออนไลน์ในยุค 90s' มาไม่มากก็น้อย เป็นยุคที่เราเน้นใช้โปรแกรมเถื่อน ขายกันอยู่ในห้างพั____พ กันฉ่ำ ๆ ตำรวจตรวจแล้วเราไม่มีขายตัว แต่เคยสงสัยถึงที่มาของโปรแกรมเหล่านี้มั้ยว่า เขา Crack กันอย่างไร วันนี้เราจะมาเล่าให้อ่านกัน...