Load Balancing Process dengan SO_REUSEPORT

Intro

Di Python, modul built-in "asyncio" memungkinkan kita menulis function non-blocking / coroutine. asyncio dibekali dengan sebuah event loop.

Saya merasakan performa asyncio masih kurang jika dibandingkan Node.js yang menggunakan libuv sebagai basis event loop.

Meski demikian, event loop di asyncio bersifat pluggable. Sederhananya bisa dicopot dan diganti dengan event loop lain.

Edit: Performa asyncio di Python 3.11 sudah setara dengan Node.js, setidaknya untuk server yang saya buat. Tetapi masih jauh dari uvloop yang ditulis dengan Cython.

uvloop

uvloop adalah event loop untuk asyncio yang berbasis libuv. Sama seperti yang digunakan pada Node.js!

Setelah saya mencoba sendiri performa uvloop sebagai event loop untuk asyncio, benar seperti yang dikatakan oleh author uvloop: https://magic.io/blog/uvloop-blazing-fast-python-networking/, bahwa performa meningkat sekitar 2x atau lebih dari Python asyncio maupun Node.js dalam menangani koneksi konkuren.

Hal ini yang menjadi salah satu motivasi untuk membuat web server/framemork sederhana tapi berperforma tinggi dengan Transport and Protocol di Python. Walau awalnya sudah ingin membuatnya dengan Go. Tapi karena masih ada ruang lain juga untuk speed-up seperti membuat modul Python dengan C. Jadi sementara stick di Python.

Semua bejalan mulus sampai saya merasakan bottleneck yaitu ketika CPU usage tinggi akan menurunkan IO. Ini terjadi karena asyncio single-threaded. Meskipun beberapa core CPU cukup idle, tidak akan membantu satu CPU yang digunakan oleh single process Python dengan asyncio.

multiprocessing

Berdasarkan kelemahan diatas, saya melakukan eksperimen untuk meminimalisir beban pada satu CPU, dengan modul multiprocessing. Setelah banyak sekali melakukan pengujian, dan berkelahi dengan berbagai pesan error, akhirnya menemukan titik cerah.

Lantas bagaimana cara mengatasinya dengan multiprocessing?

Saya menggunakan solusi paling simple, yaitu menjalankan instance event loop di tiap process. Artinya setiap process memiliki instance event loop sendiri dan tidak akan membuat sakit kepala.

Bagaimana pendistribusian resource antar process

Pada awal percobaan, saya fork / spawn child process dan setiap child process akan membuka UNIX socket untuk menerima koneksi dari parent / main process. Dan main process melakukan round-robin, mendistribusikan trafik yang diterima parent ke child process secara berurutan terus menerus. Misal jika ada 4 child berarti urutannya 1, 2, 3, 4, 1, 2, dst.

Saya pikir ini bakal ringan tetapi tetap membebani main process. Karena memang implementasi yang saya terapkan dengan Python tidak bisa se-ringan round-roubin DNS. Hmm.. sepertinya terlalu jauh, bahkan dibanding haproxy / nginx sebagai load balancer.

Round-Roubin dengan opsi socket SO_REUSEPORT

SO_REUSEPORT hadir sejak Linux 3.9. Memungkinkan beberapa process dalam kasus ini child process, untuk listen pada port yang sama. Dan kernel sendiri akan membagikan trafik ke process yang listen di port yang sama tersebut secara lebih rata menggunakan algoritma round-robin. Karena implementasi round-roubin di kernel dan tidak di koding di Python, tentu ini sangat light.

Load Balancing Process dengan SO_REUSEPORT
Sumber: https://blog.flipkart.tech/linux-tcp-so-reuseport-usage-and-implementation-6bfbf642885a

Jika opsi SO_REUSEPORT tidak didukung, misal menggunakan OS lain, bisa menggantinya dengan SO_REUSEADDR. Saya coba performa dengan SO_REUSEADDR pun tidak terasa signifikan di kasus saya. Semua child process tetap menerima beban, meski tidak tahu apa algoritma yang akan digunakan, tergantung implementasi di OS.

Kesimpulan

Web server performa tinggi dengan Python berhasil dibuat dan multiprocessing terbukti membantu memaksimalkan IO.

Saya opensource-kan di https://github.com/nggit/tremolo

Perlu digaris bawahi bahwa multiprocessing tidak untuk meningkatkan IO / IO-bound, umumnya digunakan untuk task berat / CPU-bound. Tujuan menggunakan multiprocessing pada kasus ini adalah supaya beban tidak dipikul satu process asyncio dan mengakibatkan penurunan IO.

Menambahkan child process juga tidak serta merta meningkatkan throughput. Malah bisa menurunkan pada titik tertentu. Misal jumlah worker jauh melebihi jumlah CPU core.