Tutorial

Coroutine บน Python : ปูนและอิฐสำหรับ Asynchronous Programming

By Arnon Puitrakul - 24 พฤศจิกายน 2021

Coroutine บน Python : ปูนและอิฐสำหรับ Asynchronous Programming

ปัจจุบันยอมรับว่า ลักษณะการเขียนโปรแกรมใหม่ ๆ ออกมามากขึ้นเรื่อย ๆ และการทำงานก็ซับซ้อนมากขึ้นเรื่อย ๆ การทำงานแบบเก่า ๆ อย่างพวก Thread-Blocking อาจจะไม่ใช้คำตอบของโปรแกรมในสมัยใหม่ ๆ เท่าไหร่ เพราะเรามีการทำงานที่หลากหลายมากขึ้น เราต้องการให้ CPU ทำงานได้มีประสิทธิภาพสูงสุด ทำให้การเขียนโปรแกรมแบบ Asynchronous เข้ามามีบทบาทมากขึ้น ในวันนี้เราจะพาไปรู้จัก Foundation Concept ที่เราใช้ในการเขียนโปรแกรมแบบ Asynchronous อย่าง Coroutine กันว่า ในภาษา Python เราจะ Implement มันได้อย่างไร

Basic Function in Python

def loop_me () :
    for i in range(10) :
        print(i)

โดยทั่ว ๆ ไปแล้ว เวลาเราสร้าง Function บน Python เราก็จะทำแบบด้านบน หรือเราอาจจะมีค่าอะไรบางอย่าง Return กลับไปให้ Caller หรือผู้เรียก การทำงานของมันก็จะเริ่มทำงานจากบนลงล่าง ไม่ได้มีความสามารถในการหยุด และ กลับมาทำงานต่อได้เลย พวกนี้คือ Function ที่เราใช้งานกันทั่ว ๆ ไปใน Python

Coroutine คืออะไร ?

ก่อนที่เราจะไปไหนไกล เราเริ่มจากทำความรู้จักกับคำว่า Coroutine กันก่อน ถ้าเราลองดูดี ๆ มันจะแยกออกมาเป็น 2 คำคือ คำว่า Co- ที่เป็น Prefix แปลว่า ร่วมกัน ด้วยกัน และ Routine ที่แปลว่ากิจวัตร ทำให้เมื่อมันอยู่รวมกัน มันก็น่าจะแปลประมาณว่า การทำงานร่วมกันของ Routine ที่มากกว่า 1 ตัวขึ้นไป

อ่านแล้วอาจจะ งง ว่า Routine อะไร ถ้าเรามองในแง่ของโปรแกรมมันก็คือ ส่วนหนึ่งของโปรแกรม ทำให้ในคอมพิวเตอร์ Coroutine มันก็จะพูดถึงการทำงานหลาย ๆ ส่วนของโปรแกรมนั่นเอง โดยปกติแล้วบน CPU สมัยใหม่ เรามีหลาย Core ให้เราสามารถทำงานได้ พวกนั้นเราก็อาจจะใช้พวกการแบ่ง Process หรือการทำ Threading เพื่อให้หลาย ๆ ส่วนของ โปรแกรม หรือส่วนเดียวกันแต่แบ่ง Input สามารถทำงานพร้อม ๆ กันได้ เพื่อเพิ่ม Yield ในการประมวลผลให้สูงขึ้น

ปัญหาของการทำแบบนั้นคือ เมื่อมันต้องมีการเรียกของที่มันต้องรอนาน ๆ เช่น Disk I/O และ Network I/O หรือกระทั่งรอ อีก Thread ทำงานเสร็จมันก็จะต้องอาศัยเวลา ทำให้งานบางงาน เราแบ่ง Thread เยอะ ๆ ไป มันก็ไม่ได้ช่วยอะไรเลย ถามว่าแล้วทำยังไงละ

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

Pause/Resume Function in Python

def simple_coroutine () :
    print("Part 1")
    
    yield
    
    print("Part 2")

func = simple_coroutine()
next(func)
next(func)

ใน Python เราสามารถเขียนเหมือนกับเป็น Checkpoint ใน Function ได้โดยการใช้คำสั่งที่ชื่อว่า Yield ถ้าเราคุ้นเคยกับ Generator อยู่แล้ว มันคือสิ่งเดียวกันเลย แต่แทนที่เราจะพ่นค่าอะไรสักอย่างออกมา เราก็ไม่ได้เอาค่าอะไรออกมาเลย เราแค่ให้มัน Yield ออกมาแค่นั้นเลย ส่วนด้านนอก เราก็สามารถสั่งให้ Function มันทำงานต่อได้โดยใช้คำสั่ง next

RESULT :
Part 1
Part 2
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

เมื่อเรารันออกมา เราก็จะเห็นว่า Part 1 และ 2 ทำงานออกมาได้อย่างที่เราเขียนเอาไว้ แต่เอ๊ะ มันมี Exception เด้งออกมา

def simple_coroutine () :
    print("Part 1")
    
    yield
    
    print("Part 2")

try :
    func = simple_coroutine()
    next(func)
    next(func)
except StopIteration as e:
    pass

ใช่แล้วละ มันคือ StopIteration Exception ใน Python ซึ่งเราสามารถใช้ Try Except ในการดักตรงนี้ได้ หรือเอาให้เจ๋งกว่านั้นเอา เรายังสามารถที่จะโยนค่ากลับเข้าไปใน Function ได้ด้วย ผ่านคำสั่ง send

def sending_val_back () :
    print("Part 1")
    
    a = yield
    
    print("Part", a)
    
try :
    func = sending_val_back()
    next(func)
    func.send(3)
except StopIteration as e:
    pass
RESULT:
Part 1
Part 3

จากตรงนี้ เราก็เขียน Function คล้าย ๆ เดิมเลย แต่ในตอน Part อันที่ 2 แทนที่เราจะบอกให้มันเขียน Part 2 ตรง ๆ เรากลับให้มันรับค่าเข้ามา ในที่นี้เราใส่ 3 เข้าไปผ่านคำสั่ง send ที่ด้านล่าง ทำให้ในรอบที่ 2 มันออกมาเป็น Part 3 จาก 3 ที่เราใส่เข้าไปนั่นเอง

แต่ถ้าเรามาอ่าน Code ดี ๆ แล้วเราลองอ่าน Code ดูดี ๆ เราจะเห็นจุดที่แปลก ๆ อยู่ที่นึงคือ ทำไมเราต้องเรียก next ก่อน ก่อนที่เราจะเรียก send ได้ ทั้ง ๆ ที่เราไม่ได้มี Checkpoint ก่อนหน้านิน่า นั่นเป็นเพราะ เราจะ send ได้ก็ต่อเมื่อเราอยู่ที่ Yield Checkpoint แล้วเท่านั้น ถ้าเราเรียก Send เลยมันจะพ่น Error ออกมาบอกว่ามันไม่สามารถส่งค่าที่ไม่ใช่ None ใน Generator ที่พึ่งเริ่มได้ ทำให้เราจะต้อง next มันทีนึงก่อนที่เราจะ send นั่นเอง

Simple Coroutine Functions

จาก Concept ของการ Pause/Resume Function ในหัวข้อก่อนหน้า เราก็สามารถเอาเรื่องตรงนี้แหละ มาใช้ในการทำ Coroutine ระหว่าง 2 Function ด้วยกัน

def function_1 () :
    print("Function 1 Part 1")
    
    yield
    
    print("Function 1 Part 2")
    
    yield
    
    print("Function 1 Part 3")
    
def function_2 () :
    print("Function 2 Part 1")
    
    yield
    
    print("Function 2 Part 2")
    
    yield
    
    print("Function 2 Part 3")

try :
    func_1 = function_1()
    func_2 = function_2()
    
    next(func_1)
    next(func_2)
    next(func_1)
    next(func_2)
    next(func_1)
    next(func_2)
except StopIteration as e:
    pass
OUTPUT: 
Function 1 Part 1
Function 2 Part 1
Function 1 Part 2
Function 2 Part 2
Function 1 Part 3

ในตัวอย่างนี้เราพยายามที่จะทำให้ 2 Function มีการทำงานร่วมกันใน Thread เดียวกัน แต่สลับกันทำงานไปมา Pattern ที่เราใช้ มันก็เหมือนกับก่อนหน้าที่เราทดลองกันมาหมดเลย แค่แทนที่เราจะใช้แค่ Function เดียว เราก็เอา 2 Function มาใส่ด้วยกัน

สรุป

Coroutine เป็น Concept นึงที่เรียกได้ว่าเป็นเหมือนกับ อิฐ และ ปูนในการเขียนโปรแกรมแบบ Asynchronous เลยก็ว่าได้ มันทำให้เราสามารถรันหลาย ๆ Function หรือหลาย ๆ ส่วนของโปรแกรมใน Thread เดียวกัน ในส่วนของ Python เองเราก็สามารถ Implement Concept นี้ลงไปได้เช่นกันผ่านการใช้งานพวก Generator จากตัวอย่างที่เราลอง Implement Coroutine ให้ดูเป็นแค่พื้นฐานเท่านั้น แต่เราสามารถเอา Concept ตรงนี้แหละ ไปประกอบร่างรวมกับพวก Scheduler เพื่อทำให้มันทำงานในแบบที่เราต้องการได้เลย ตัวอย่างของหลาย ๆ Application ที่เอาเรื่องนี้ไปใช้พวก asyncio ที่เป็น Built-in Module ใน Python

Read Next...

จัดการข้อมูลบน Pandas ยังไงให้เร็ว 1000x ด้วย Vectorisation

จัดการข้อมูลบน Pandas ยังไงให้เร็ว 1000x ด้วย Vectorisation

เวลาเราทำงานกับข้อมูลอย่าง Pandas DataFrame หนึ่งในงานที่เราเขียนลงไปให้มันทำคือ การ Apply Function เข้าไป ถ้าข้อมูลมีขนาดเล็ก มันไม่มีปัญหาเท่าไหร่ แต่ถ้าข้อมูลของเราใหญ่ มันอีกเรื่องเลย ถ้าเราจะเขียนให้เร็วที่สุด เราจะทำได้โดยวิธีใดบ้าง วันนี้เรามาดูกัน...

ปั่นความเร็ว Python Script เกือบ 700 เท่าด้วย JIT บน Numba

ปั่นความเร็ว Python Script เกือบ 700 เท่าด้วย JIT บน Numba

Python เป็นภาษาที่เราใช้งานกันเยอะมาก ๆ เพราะความยืดหยุ่นของมัน แต่ปัญหาของมันก็เกิดจากข้อดีของมันนี่แหละ ทำให้เมื่อเราต้องการ Performance แต่ถ้าเราจะบอกว่า เราสามารถทำได้ดีทั้งคู่เลยละ จะเป็นยังไง เราขอแนะนำ Numba ที่ใช้งาน JIT บอกเลยว่า เร็วขึ้นแบบ 700 เท่าตอนที่ทดลองกันเลย...

Humanise the Number in Python with "Humanize"

Humanise the Number in Python with "Humanize"

หลายวันก่อน เราทำงานแล้วเราต้องการทำงานกับตัวเลขเพื่อให้มันอ่านได้ง่ายขึ้น จะมานั่งเขียนเองก็เสียเวลา เลยไปนั่งหา Library มาใช้ จนไปเจอ Humanize วันนี้เลยจะเอามาเล่าให้อ่านกันว่า มันทำอะไรได้ แล้วมันล่นเวลาการทำงานของเราได้ยังไง...

ทำไม 0.3 + 0.6 ถึงได้ 0.8999999 กับปัญหา Floating Point Approximation

ทำไม 0.3 + 0.6 ถึงได้ 0.8999999 กับปัญหา Floating Point Approximation

การทำงานกับตัวเลขทศนิยมบนคอมพิวเตอร์มันมีความลับซ่อนอยู่ เราอาจจะเคยเจอเคสที่ เอา 0.3 + 0.6 แล้วมันได้ 0.899 ซ้ำไปเรื่อย ๆ ไม่ได้ 0.9 เพราะคอมพิวเตอร์ไม่ได้มองระบบทศนิยมเหมือนกับคนนั่นเอง บางตัวมันไม่สามารถเก็บได้ เลยจำเป็นจะต้องประมาณเอา เราเลยเรียกว่า Floating Point Approximation...