Tutorial

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

By Arnon Puitrakul - 24 พฤศจิกายน 2021 - 1 min read min(s)

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