Web กับ process thread และอื่น ๆ
ผมไม่ค่อยแม่นเรื่องนี้เท่าไหร่ถ้ามีอะไรมั่วไปก็ขออภัยด้วยครับ สามารถพูดคุยกันเพิ่มเติมได้ที่ veer66@mastodon.in.th
เขียนโปรแกรมบน web server ยุค 90 ส่วนมากก็ผ่าน CGI เอาครับ สมัยนั้นใช้ C, Perl, Bash เขียนกัน URL มันก็จะประมาณ http://localhost/cgi-bin/do_sth.pl งี้เลย web server มันก็จะเรียก do_sth.pl ให้อีกที ถ้าเป็นบน GNU/Linux ก็คือต้อง fork/spawn process ขึ้นมาใหม่เลย
ปลาย ๆ ยุค 90 ค่าย Java ก็มีตัวเลือกมาให้อีกแบบคือใช้ Servlet ซึ่งตัวนี้ใช้หลาย ๆ thread เอาแทนทีจะมา spawn process ใหม่ ซึ่งผมเดาว่าเวลา implement เขาก็น่าจะมี thread pool ตั้งแต่ตอนนั้น พูดอีกอย่างคือเอา thread สร้างมาแล้วมาใช้ซ้ำ
PHP ก็มาปลาย ๆ ยุค 90 เหมือนกัน แต่ผมเริ่มใช้ทีแรกก็เป็น mod_php ที่ฝังมาใน Apache Httpd แล้ว เวลาใช้จริง ๆ มันก็ fork process ใหม่อยู่ดี แต่ว่าก็มี prefork ไว้ก่อนได้ ถ้าผมเข้าใจไม่ผิดแต่ละ process ก็จะมี PHP runtime พร้อม extension ต่าง ๆ อยู่ใน memory ด้วย อันนี้น่าจะเป็นเหตุผลหลัก ๆ ที่หลาย ๆ ท่านบ่นกันว่ามันกิน RAM
ยุค 90 MS ก็มี ASP ออกมา แต่ผมไม่รู้เลยว่าทำงานอย่างไร ก็เลยข้ามไปแล้วกัน
หลังจากที่ไม่ค่อยจะมีอะไรน่าสนใจออกมาสักพัก กลาง ๆ ยุค 2000 ก็มี Ruby on Rails ออกมา ผลที่ตามมาคือมี web server ที่เอามาใช้รัน Rails ได้หลายตัว ซึ่งผมไม่รู้เลยว่าตอนนั้นเขาทำแบบไหนกัน 😅; ตอนนั้นผมใช้ TurboGears ก่อน 1.0 (บน Python) ส่วน Web Server ใช้ CherryPy ผมลองดู version เก่า ๆ ก็ใช้หลาย thread แล้วมี threadpool ด้วย
ราว ๆ ค.ศ. 2008 ผมก็เริ่มได้ยินพี่ป็อกพูดถึง Erlang ซึ่ง Erlang นี่ผมก็เพิ่งมาทราบทีหลังว่ามาในชุมสายโทรศัพท์ตั้งแต่ยุค 80 แล้ว ใน Erlang ใช้คำว่า process แทน thread แต่ว่าเอาจริง ๆ มันไม่ใช่ process ของ Linux หรือ BSD แบบของ Apache Httpd มันคล้าย green thread ของ Java ยุคแรก ๆ มากกว่า process ของ Linux แต่ว่าเรียกว่า process เพราะว่าไม่ได้ share memory แบบที่ thread ส่วนมากทำ Erlang process ใช้ memory อยู่ในวิสัยที่จะ spawn มาเป็นแสนตัวได้ สลับ ๆ ไปสลับมาแบบ preemptive ต่อให้ไม่มี I/O หรือไปสั่งให้สลับ scheduler มันก็จะสลับให้ แต่ถึงมันจะสร้าง process ได้เยอะแยะแต่ก็เหมือนว่าเขาจะไม่ได้สร้างกันทุกครั้งที่ request เข้ามา แต่ว่าไปใช้ gen_server ซึ่งถ้าผมเข้าใจไม่ผิดมันทำให้ reuse process เดิมได้
ยุคหลัง 2010 คนเริ่มเปลี่ยนมาใช้ node.js ซึ่งใช้ประโยชน์จาก kernel polling เช่น epoll และ kqueue เอามาทำ event loop พอมีงานที่ต้องรอ I/O ก็เอาออกไปรอก่อนแล้วสลับไปรับงานอื่นได้ แต่ว่า JavaScript แต่เดิมมันแตก thread ไม่ได้ node.js ก็แตก thread ไม่ได้เหมือนกัน เวลาจะใช้ประโยชน์จาก CPU หลาย ๆ core ก็เลยต้องแตก process ของ Linux หรือ OS อื่น ๆ เอาแล้วก็มี runtime ไปอยู่ทุก process เลย
พอเริ่มใช้ node.js ไม่เท่าไหร่ ก็มี Golang จาก Google ออกมา ซึ่งจะมองว่า Golang ก็คือแบบที่ใช้ multithread เลยก็ได้ พวกตัวที่เร็ว ๆ เช่น fasthttp ก็มี thread pool คล้าย ๆ กับ web server ก่อนหน้านี้ แต่ว่า thread ของ Golang หรือที่เรียกว่า Goroutine มันใช้ memory น้อยไม่ใช่ thread ของ OS ตรง ๆ ทำให้ spawn ออกมาเยอะ ๆ ได้ fasthttp อาจจะเป็นไปได้ว่า spawn ออกมาหลักแสนตัวได้เลย แล้วก็ปล่อย scheduler ของ Go runtime ไปจัดลง thread ของ OS เอาเอง จะเห็นว่าคล้าย ๆ Erlang อยู่บ้าง แต่ว่า Goroutine อย่างน้อยก็ช่วงนี้ยังไม่ได้สลับแบบ preemptive
วงการ Rails เองก็มีการปรับตัว Puma ที่เป็น default web server ทุกวันนี้ก็เป็นแบบ multithread โดยที่ค่า default คือให้เปิดมาได้เลย 5 thread แต่ใน production จะใช้มากกว่านี้ก็ได้ เทียบกับตัวเก่า ๆ Puma รับ concurrency ได้มากขึ้นใช้ RAM น้อยลง เท่าที่ผมเห็นก็คือมีทั้ง worker thread ที่มาประมวลผล โดยที่ก็จะวน ๆ ใช้จาก thread pool ไม่ใช่ว่าสร้างใหม่บ่อย ๆ มีอีก thread นึงคือไป accept request แล้วอ่านข้อมูลมารอใส่ queue ชื่อ todo ไว้ นอกจากนั้น parser ก็เขียนด้วย C ทำให้เร็วขึ้นไปอีก Puma นี่มันเลือกแตก process ผสมกับ Thread ได้ด้วย ผมเดาว่าเพราะ MRI-Ruby มันติด global lock ถ้าเจองานใช้ CPU มาก ๆ อาจจะใช้ได้ไม่ทั่วถึง แต่ถ้าไปใช้ JRuby แทนก็เป็นอันตกไป
Tokio เขียนบน Rust ที่มี mio ที่ไปหุ้ม epoll และ kqueue ได้เหมือน node.js ถ้าเทียบกันน่าจะอยู่ในระดับเดียวกับ libuv แต่ว่า Rust ไม่มีปัญหาเรื่องแตก thread อยู่แล้ว Tokio ก็เลยทำได้ทั้งแตก thread (multithread) แล้วก็มี event loop แบบ node.js ด้วย ในแต่ละ thread ก็มี event loop เองเลย
อีกวิธีหนึ่งของค่าย Rust คือทำ coroutine คล้าย ๆ Goroutine ขึ้นมาเลยใน project ชื่อ May แต่ทั้ง May และ Tokio นี่มันเป็นระดับล่างมาก ๆ ผมเข้าใจว่าคนทำเว็บส่วนมากคงไม่มาเรียกตรง ๆ น่าจะใช้ผ่าน Rocket หรือ Actix แทน
Rust นี่แตก thread กันเป็นว่าเล่นส่วนหนึ่งอาจจะเพราะว่ามันไม่มี GC ส่วนหนึ่งที่ยากเวลาแตก thread คือต้องทำ GC ให้ใช้งานสอดคล้องกับ scheduler ด้วย ผมเห็นสองภาษาที่โมเรื่องนี้อยู่คือ Ocaml และ Crystal ถ้าเสร็จแล้วก็น่าจะจัดอยู่กลุ่มเดียวกับ Golang ได้ แต่อาจจะเขียนง่ายกว่า