Tutorial

Python Global Interpreter Lock แบบเข้าใจง่าย?

By Arnon Puitrakul - 17 September 2020 - 2 min read min(s)

Python Global Interpreter Lock แบบเข้าใจง่าย?

Python เป็นภาษาที่ได้รับความนิยมสูงมาก ๆ ในการทำงานในปัจจุบัน โดยเฉพาะการทำงานกับข้อมูล ที่มี Library ที่ช่วยให้เราลดเวลาในการทำงานได้เยอะมาก แต่เมื่อเราเริ่มทำงานกับข้อมูลที่มีขนาดใหญ่ขึ้น Performance กลับไม่ได้ดีอย่างที่คิด โดยเฉพาะเมื่อเราเริ่มเขียนให้เป็น Multithread Application พอลองไปหาข้อมูลดู มันก็จะไปลงเอยที่คำว่า Global Interpreter Lock (GIL) วันนี้เราจะมาดูกันว่า มันคืออะไร และ เราจะทำอย่างไรกับมันดี

How Python works?

เวลาเราเขียนโปรแกรมในภาษา Python เราจะเริ่มจากการเขียนออกมาให้อยู่ในไฟล์สกุล py และ เราก็สั่งรันได้จาก Python เลย ตอนนี้เราจะมาแคะให้ดูกันว่า ระหว่างทางก่อนที่จะมาเป็น Output มันต้องผ่านอะไรบ้าง

สิ่งนึงที่เราต้องทำความเข้าใจคือ เครื่องไม่เข้าใจภาษา Programming ที่เราเขียนเลย แต่สิ่งที่เครื่องคอมพิวเตอร์เข้าใจได้คือ Machine Code ที่มันจะมีรายละเอียดในการบอก CPU ว่า มันต้อง Execute อะไรอย่างไรบ้างในระดับ Low-Level มาก ๆ

Compilation Programming Language

ในภาษาบางตัวอย่าง C และ C++ เองเวลาเราเขียน Code ออกมา มันจะไม่สามารถรันได้ เพราะมันอยู่ในรูปแบบที่เครื่องไม่เข้าใจ เราจะต้องทำสิ่งที่เรียกว่า Compile ก่อน อาจจะใช้พวก gcc ในการ Compile ก็ได้ ซึ่งจริง ๆ การ Compile มันก็คือการแปลงจาก Code ที่เราเขียนให้กลายเป็น Machine Code ที่เครื่องเข้าใจได้ ทำให้เวลารัน เครื่องก็สามารถอ่าน Machine Code ที่ Compile แล้วรันได้เลย

Python Interpreter
Interpreter คือ ส่วนของ Compiler ที่แปลง Source Code ให้เป็น Byte Code พร้อมกับ Virtual Machine สำหรับรัน Byte Code

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

ถามว่าทำไม Python ต้องทำแบบนี้ละ ส่วนนึงเป็นเพราะการทำงานในหลาย ๆ Platform เพราะถ้าเราเปลี่ยน Code เราเป็น Bytecode และทำงานได้บน Virtual Machine นั่นแปลว่า เราสามารถเขียนครั้งเดียวแล้วเอาไปรันที่ไหนก็ได้เลย แต่สิ่งที่เราว่ามันดีมากคือการใช้ Dynamic Typing ง่าย ๆ คือการระบุประเภทของตัวแปร ถ้าเป็น C เราต้องการประกาศตัวแปรสำหรับตัวเลขเราก็ต้องใช้

int a;
a = 10;
a = ""
การประกาศตัวแปรสำหรับเก็บ ตัวเลข ในภาษา C

ถ้าเรารัน Code ด้านบน เราก็จะเจอกับ Error เพราะว่า เราบอกไว้ว่า a คือตัวเลข ตอนที่เราบอกให้มันเท่ากับ 10 มันไม่เป็นไรเลย เพราะมันก็เป็นตัวเลขซึ่งถูกอย่างที่เราประกาศไว้ แต่เมื่อเราให้มันเท่ากับ String ว่างซึ่งมันก็เป็น String ตรงนี้แหละที่มันจะ Error

a = 10
a = ""

แต่ใน Python ที่เป็น Dynamic Typing ความง่ายคือ ตัวแปรของเราสามารถเปลี่ยนประเภทระหว่างการรันโปรแกรมได้นั่นเอง ถ้าเราต้อง Compile ล่วงหน้าเราจะทำอะไรแบบนี้ไม่ได้เลย เพราะ Compiler จะอ่าน และ ล๊อคไว้เลยว่า ตัวแปรนี้คือ Type  ไหนและขนาดเท่าไหร่ ทำให้เราว่า Dynamic Typing เป็นอะไรที่เจ๋งมาก

ลองรัน Python Script สักตัวนึง เราจะเจอกับ Folder ตัวนึงที่ชื่อว่า "__pycache__" ถ้าเราเข้าไป เราจะเจอกับไฟล์สกุล pyc นั่นแหละ มันคือ Bytecode ของ Python

ซึ่งจริง ๆ แล้ว Python ไม่ได้มีแค่ Python เออ งง ม่ะ ชั้นก็ งง แต่นั่นแหละ มันเป็นแบบนั้นจริง ๆ คือ Python ที่เราใช้งานกันเป็นส่วนใหญ่คือ CPython หรือก็คือ Python ที่เขียนมาจากภาษา C ทำให้บางครั้งอาจจะเคยเห็นเคสที่เราเขียน Library ที่เป็นภาษา C แล้วเราก็เอามารันกับ Code ที่เป็น Python ของเราได้ ดูประหลาดใช่ม่ะ แต่จริง ๆ แล้วมันไม่ได้ประหลาดเลย

หรือ Python ที่ใช้ Interpreter ที่เขียนด้วยภาษาอื่น ๆ เช่น JPython ที่มาจาก Java ซึ่ง GIL เจ้าปัญหาของเราในวันนี้ มันอยู่ใน CPython ที่เราใช้งานอย่างแพร่หลายนั่นเอง

Global Interpreter Lock (GIL) คืออะไร ?

Python Global Interpreter Lock

Global Interpreter Lock (GIL) คือกลไกที่ใช้ในการ ล๊อค 🔒 Interpreter ไว้ ไม่ให้มันทำงานพร้อม ๆ กันหลาย ๆ งานในเวลาเดียวกัน อย่างเราเราบอกคือ Python ไม่เหมือนภาษาจำพวก C และ C++ ที่มันจะต้องแปลงจาก Code ให้เป็น Bytecode และต้องใช้ Interpreter ในการอ่านเพื่อที่จะให้มันทำงานได้

กลไกการทำงานของมันคือ ถ้าเราทำงานพร้อม ๆ กันหลาย ๆ อย่าง Thread ที่ได้ทำงานจะไปเรียกใช้ Interpreter และ GIL จะทำการ Lock เพื่อไม่ให้ Thread อื่นเข้าถึง Interpreter ได้ นั่นทำให้ Thread อื่น ๆ ที่รอรันอยู่ไม่สามารถทำงานได้

ถามว่า แล้ว Thread อื่น ๆ จะได้ทำงานเมื่อไหร่ ก็มีอยู่ 2 กรณีใหญ่ ๆ คือ Thread นึงทำงานเสร็จแล้ว หรือ มันต้องรออะไรบางอย่างเช่น I/O ที่กินเวลาเยอะมาก มันก็จะปล่อย Interpreter และ GIL จะทำการ Unlock Interpreter เพื่อให้ Thread อื่นที่ต้องการทำงาน และเรียกไป วนแบบนี้ไปเรื่อย ๆ

GIL ของ Python 2 vs 3

เรื่องนึงที่ Python 2 และ 3 มีความแตกต่างกันคือเรื่องของ GIL เพราะกลไกการทำงานค่อนข้างต่างกันมาก ปัญหาเมื่อเรา Implement Python คือ ถ้าเราบอกว่า GIL จะปล่อย Interpreter ให้ Thread อื่นก็ต่อเมื่อ มีการเรียกใช้งาน I/O หรือทำงานจนเสร็จเลย คำถามคือ แล้วอีก GIL จะรู้ได้ยังไงว่า Thread ที่เรียกใช้งานนั้นจะทำงานเสร็จเมื่อไหร่ คำตอบคือไม่รู้

Python Global Interpreter Lock in Python 2

ทำให้ใน Python 2 นำ Concept ของ Tick เข้ามาใช้ กล่าวคือ GIL จะค่อย ๆ เช็คสถานะของ Thread ที่ทำงานเรื่อย ๆ เช่น 100 Ticks ก็เช็คทีนึง เหมือนกับ เราเดินไปตามงานเพื่อนเลย เห้ย ๆ เสร็จยังอะ ถ้าเราเช็คบ่อยไป มันก็จะเป็นการเสียเวลาด้วยเหมือนกัน ซึ่ง Tick ในที่นี้เราไม่ได้พูดถึงเวลานะ 1 Tick คือ 1 Byte Code ที่ถูกทำงานไป เช่น ถ้าเราบอกว่า 100 Ticks คือ Byte Code ทำงานไปแล้ว 100 คำสั่ง ซึ่งในแต่ละคำสั่งมันก็ย่อมใช้เวลาไม่เท่ากัน ทำให้เราบอกไม่ได้เหมือนกันว่า 1 Tick คือเท่าไหร่ ขึ้นกับเราเรียกอะไรมากกว่า

บางที การทำงานของเราอาจจะเป็นงานที่ ทำพวก I/O เยอะมาก อาจจะเร็วกว่าที่ GIL จะถึงเวลาเข้ามาเช็คทำให้การทำงานช้าลง เราสามารถปรับค่าจำนวน Tick ในการเช็คได้ ผ่าน Built-in Module sys ได้เลย

ความฮ่า ของวิธีนี้คือ ข้างในโปรแกรมเราคือสงครามที่ฟาดกันเพื่อแย่ง GIL กันเลย เพราะ ถ้าเราบอกว่า Thread จะเรียก Status ทุก 100 Ticks ถ้าเกิด Thread ที่รอ ตื่นมาเช็คแล้วเห็นว่า อ้าวไม่ว่างก็นอนต่อไป 100 Ticks แล้ว Thread ที่ทำงานอยู่ปล่อยออกมา Thread ที่ว่างมันก็จะตื่น ในระหว่างนั้น Thread ที่พึ่งปล่อยก็เช็ค แล้วมันก็เจอว่าว่างมันก็เอา Interpreter ไปรันซะเลย Thread ที่ว่างตื่นมาเช็คอ้าวไม่ว่างอีก ก็รอต่อไป แบบนี้ไปเรื่อย ๆ นี่แหละ คือสงครามการฟาดกันระหว่าง Thread จนสุดท้ายมันจะคาดเดาไม่ได้เลยว่า Thread ไหนจะได้ทำงานก่อนกัน ก็คือ มั่วไปหมดเลย

Python Global Interpreter Lock in Python 3.2 and up

แต่ใน Python 3 มันดีกว่านั้นอีกคือ มันใช้ Concept ของการ Fixed เวลาสูงสุดในการทำงานไปเลย เช่นเราบอกว่า Thread นึงทำงานได้ 10ms แล้วต้องปล่อยให้ Thread อื่นรันต่อ พูดอีกนัยนึงคือ Thread ก็จะเหมือนตั้งเวลาเลยว่าจะต้องปล่อย และ เตรียมรับในเวลาเท่าไหร่ นั่นทำให้ลดการต้องเดินไปถามเรื่อย ๆ ได้อย่างมาก

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

Python Global Interpreter Lock Signalling and ACK between Threads

พอ Thread ปล่อยแล้ว มันจะไปบอกอีก Thread ว่า เออ เราว่างแล้วนะ จากนั้นอีก Thread ก็ทำงานได้เลย เพื่อแก้ปัญหาการฟาดฟันระหว่าง Thread เราแก้ปัญหาด้วยการที่ เมื่อมี Thread รับไปแล้ว มันจะยิง ACK Signel ออกมา บอกว่าเราเอาไปแล้วนะ พวกแกถอยได้แล้ว นี่แหละคือการทำงานของ GIL ใน Python 3

ทำไม Python ต้องมาทำอะไรแบบนี้ด้วย ?

อ่านมาถึงตรงนี้ หลาย ๆ คนอาจจะถามว่า ทำไม CPython ต้องหาทำ เอา GIL Implement ลงมาด้วยทำไม ก่อนอื่น เราอยากจะบอกว่า เราไม่อยากให้ทุกคนมอง GIL เป็นคนร้ายอะไร เพราะจริง ๆ การที่มี GIL มันช่วยเราจัดการเรื่องที่เราไม่อยากยุ่งในระดับ Low-Level เช่น Memory Management ตัวอย่างที่เราจะเห็นได้ถ้าเราเขียนพวกภาษาที่มันสามารถจัดการ Memory เองได้อย่าง C และ C++ เราสามารถเรียก Malloc() เพื่อทำการจองพื้นที่ใน Memory ได้โดยตรงเลย ในขณะที่ภาษาที่สูงกว่านั้นอย่าง Python ไม่มีอะไรแบบนี้เลยนะ

ที่ถ้าเราจะเขียนพวก Multithread Application เราจะต้องมาจัดการกับพวก Critical Part หรือป้องกันการอ่าน และ เขียนข้อมูลระหว่าง Thread กันอีกพวก MESI Protocol หรืออาจจะล๊อคไปเลยด้วย Mutex Lock, Semaphore อะไรพวกนั้น ที่มันเป็นเรื่องของ Low-Level Programming ไม่เหมาะกับมือใหม่มาก ๆ

ทำให้การมี GIL พวกนี้ เราว่ามันทำให้ Python เป็นภาษาที่ High-Level สมชื่อมัน เพราะมันจัดการเรื่องที่มันยุ่งยากในระดับ Low-Level ให้เราหมด เราโฟกัสกับ Logic ของโปรแกรมเราได้อย่างเต็มที่ แต่นั่นแหละ มันก็มากับปัญหาถ้าเราต้องการที่จะจัดการเอง มันก็ทำไม่ได้ไง ทำให้นำไปสู่สิ่งที่เราอยากจะบอกคือ

There's no such a thing that works for all solutions.

มันไม่มีของที่ดีสำหรับทุก Solution หรอก Python ก็ไม่ใช่หนึ่งในนั้น ถ้าเราอยากจะทำงานที่มันต้อง Parallel มาก ๆ Python อาจจะไม่ใช่คำตอบ จริง ๆ Go อาจจะเป็นคำตอบที่ดูดีกว่าก็ได้ในการได้ภาษาที่ High-Level มากหน่อย แต่หย่อนให้เราจัดการสิ่งที่มันอยู่ใน Low-Level ได้

อีก Solution นึงที่นึกถึง มันค่อนข้างจะหาทำมาก คือ การที่เราเขียนส่วนที่เราต้องการ Multithread จริง ๆ ด้วย Python แล้วส่วนที่เหลือเราก็เขียนบน C เอา แล้วใช้ CPython ก็น่าจะสามารถเรียกใช้งานกันได้มั่งนะ เดา....

ฟาดมันค่ะแม่ ! เป่านกหวีดไล่ GIL ออกไป

การเอา GIL ออกไป อื้ม... คิดยังไงละ อันนี้เราว่าหลาย ๆ คนอาจจะมีความเห็นต่างกัน สำหรับเรา การเอา GIL ออกไปมันทำให้เกิดข้อเสียมากกว่าข้อดีแน่ ๆ สู้เราหาวิธีอยู่ร่วมกับมันดีกว่า นอกจากนั้น การเอา GIL ออกไป เราว่ามันทำให้ Python สูญเสีย Concept ของคำว่า High-Level Language ไปเลย

สรุป

วันนี้เรามาเล่าเรื่องของ GIL ที่เป็นเหมือนกลไกในการควบคุมการทำงานโปรแกรมที่เขียนด้วย Python ที่ทำให้เราไม่สามารถรันพร้อมกันหลาย ๆ Thread ได้ นั่นช่วยตัดปัญหาเรื่องของการจัดการ Memory ได้อย่างดี แต่การออกมาพูดเรื่องนี้เราไม่ได้บอกว่าเราไม่สนับสนุนการทำ Multithread ใน Python นะ เพราะการทำมันก็ยังเป็นเครื่องมือที่ดีในการทำงานแบบ Concurrent แม้ว่าจะมี GIL ก็เถอะ สิ่งที่เราทำได้คือ การ Workaround กับมัน หรือ เปลี่ยนภาษาที่ใช้ไปเลย น่าจะง่ายกว่า ฮ่า ๆ

สิ่งที่เราอาจจะตกขาดไปคือพวกเรื่องของ OS Scheduling และ Context Switching เราละไว้เพื่อให้อธิบายได้ง่ายขึ้น สำหรับใครที่อยากศึกษาเพิ่มเติมไปอ่าน 2 เรื่องนี้ และ ลองแคะเข้าไปใน CPython เราจะเจอกับพวกกลไกการล๊อคต่าง ๆ ได้อย่างดี (แนะนำไปอ่านพวกเรื่อง Mutex Lock และ pthread ดี ๆ ช่วยได้มาก) และท้ายสุดไปดู Slide เรื่อง Understanding the Python GIL ของ David Beazley เขาทำไว้ละเอียดมาก ๆ เราก็อ้างอิงมาจากตรงนี้เลย เอารูปมาทำให้ใหม่แก้นิดหน่อย

ก่อนจะมาเขียน บทความนี้ เราไปนั่งแคะ CPython อยู่เป็นวัน เจออะไรที่น่าสนใจเยอะมาก แนะนำเลย ถ้าว่างลองไปแคะดู มันช่วยให้เราจัดการกับ GIL ได้ดีขึ้นมากจริง ๆ