はじめに
株式会社iimonでSREエンジニアをしているhogeです。
本記事はiimonアドベントカレンダー9日目の記事となります。
今回の記事は技術的な棚卸しとして、普段大変お世話になっているWebサーバがどういった仕組みで動いているのかを実装しながら深堀りしていこうと思います。
弊社のバックエンドはDjango/FastAPI + Gunicornの構成で動作しているため、Pythonを絡めた説明が多くなるかと思います。サンプルコードもPythonで実装をしています。
途中、システムコールやファイルディスクリプタなどにも踏み込んだ話をするのですが、低レベルなプログラミングをちゃんとやったことがないため、間違えている部分があるかもしれません。今後学習して行く中で気づいたら都度修正していきたいと思います。
環境・使用ツール
言語
OS
HTTPクライアントツール
- curl
- siege
サンプルコード
まずは反復モデルで作ってみる
並行処理はせずに、逐次に処理するだけのサーバモデルです。
例えばDjangoのローカル環境で使用する開発サーバが反復モデルで動作します。
実装例:app.py
import socket def view(raw_request): print(raw_request) return 'HTTP/1.1 200 OK\n\nHello, World!' def main(): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(('127.0.0.1', 8800)) s.listen() while True: conn, addr = s.accept() with conn: raw_request = b'' while True: chunk = conn.recv(4096) raw_request += chunk if len(chunk) < 4096: break raw_response = view(raw_request.decode('utf-8')) conn.sendall(raw_response.encode('utf-8')) if __name__ == '__main__': main()
- socketで8080ポートで開く
- HTTPリクエストを受け付ける
- HTTPレスポンスを返す
というシンプルな処理を行っています。エラーハンドリングやちゃんとしたリクエストの処理は行っていません(本記事ではこの辺は対象外とさせてください)
Socketとは何か
Socketは同一または異なるマシンシステムにおける「プロセス間」同士が通信をやりとりするときの共通 APIです。より具体的にいうと、ネットワーク通信に用いる際のファイルディスクリプタです。
Socketは、サーバーが「リクエストを待つ」役割(bind & listen)と、クライアントからの「接続を受け入れる」役割(accept)を果たします。これにより、サーバとクライアントがデータをやり取りできます。
ファイルディスクリプタ
プロセスがファイルを読み書きしたり他のプロセスとやりとりするときにはストリームを使います。プログラムからストリームを扱うにはファイルディスクリプタというものを使います。ファイルとつきますが、扱えるものはファイルだけではなく、ソケットなどのリソースもファイルのように扱うことができます。だからファイルディスクリプタという名称になっているのかもしれませんね。
プログラムはファイルディスクリプタを指定してストリーム(バイト列を流す配管のようなもの)を指定し、そこに read / write をすることでプロセスが値を取り出したり読み出したりすることができます。
ファイルディスクリプタを作成するシステムコールとして以下があります。
- open()
- creat()
- socket()
- accept()
- socketpair()
- pipe()
- epoll_create() (Linux)
- signalfd() (Linux)
- eventfd() (Linux)
- timerfd_create() (Linux)
- memfd_create() (Linux)
- userfaultfd() (Linux)
- fanotify_init() (Linux)
- inotify_init() (Linux)
- clone() (with flag CLONE_PIDFD, Linux)
- pidfd_open() (Linux)
- open_by_handle_at() (Linux)
- kqueue() (BSD)
- pdfork() (kFreeBSD)
ファイルディスクリプタを使った読み書きの観察
例としてファイルを読み込んで標準出力に表示するcatコマンドのシステムコールを観察してみます。
strace cat text.txt
strace出力
root@b4725c01206a:/work# strace cat text.txt execve("/usr/bin/cat", ["cat", "text.txt"], 0xffffe4a8bf78 /* 13 vars */) = 0 brk(NULL) = 0xaaab13866000 mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xffff8a501000 faccessat(AT_FDCWD, "/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory) openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3 newfstatat(3, "", {st_mode=S_IFREG|0644, st_size=24198, ...}, AT_EMPTY_PATH) = 0 mmap(NULL, 24198, PROT_READ, MAP_PRIVATE, 3, 0) = 0xffff8a4fb000 close(3) = 0 openat(AT_FDCWD, "/lib/aarch64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3 read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0\267\0\1\0\0\0py\2\0\0\0\0\0"..., 832) = 832 newfstatat(3, "", {st_mode=S_IFREG|0755, st_size=1651472, ...}, AT_EMPTY_PATH) = 0 mmap(NULL, 1826976, PROT_NONE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xffff8a309000 mmap(0xffff8a310000, 1761440, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0) = 0xffff8a310000 munmap(0xffff8a309000, 28672) = 0 munmap(0xffff8a4bf000, 32928) = 0 mprotect(0xffff8a497000, 86016, PROT_NONE) = 0 mmap(0xffff8a4ac000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x18c000) = 0xffff8a4ac000 mmap(0xffff8a4b2000, 49312, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0xffff8a4b2000 close(3) = 0 set_tid_address(0xffff8a5020b0) = 245 set_robust_list(0xffff8a5020c0, 24) = 0 rseq(0xffff8a502700, 0x20, 0, 0xd428bc00) = 0 mprotect(0xffff8a4ac000, 16384, PROT_READ) = 0 mprotect(0xaaaae875f000, 4096, PROT_READ) = 0 mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xffff8a4f9000 mprotect(0xffff8a506000, 8192, PROT_READ) = 0 prlimit64(0, RLIMIT_STACK, NULL, {rlim_cur=8192*1024, rlim_max=RLIM64_INFINITY}) = 0 munmap(0xffff8a4fb000, 24198) = 0 getrandom("\xdd\x51\x56\x2d\x60\x05\x4c\x2a", 8, GRND_NONBLOCK) = 8 brk(NULL) = 0xaaab13866000 brk(0xaaab13887000) = 0xaaab13887000 openat(AT_FDCWD, "/usr/lib/locale/locale-archive", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory) openat(AT_FDCWD, "/usr/share/locale/locale.alias", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory) openat(AT_FDCWD, "/usr/lib/locale/C.UTF-8/LC_IDENTIFICATION", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory) openat(AT_FDCWD, "/usr/lib/locale/C.utf8/LC_IDENTIFICATION", O_RDONLY|O_CLOEXEC) = 3 newfstatat(3, "", {st_mode=S_IFREG|0644, st_size=258, ...}, AT_EMPTY_PATH) = 0 mmap(NULL, 258, PROT_READ, MAP_PRIVATE, 3, 0) = 0xffff8a500000 close(3) = 0 openat(AT_FDCWD, "/usr/lib/aarch64-linux-gnu/gconv/gconv-modules.cache", O_RDONLY) = 3 newfstatat(3, "", {st_mode=S_IFREG|0644, st_size=27028, ...}, AT_EMPTY_PATH) = 0 mmap(NULL, 27028, PROT_READ, MAP_SHARED, 3, 0) = 0xffff8a4f2000 close(3) = 0 futex(0xffff8a4b18cc, FUTEX_WAKE_PRIVATE, 2147483647) = 0 openat(AT_FDCWD, "/usr/lib/locale/C.UTF-8/LC_MEASUREMENT", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory) openat(AT_FDCWD, "/usr/lib/locale/C.utf8/LC_MEASUREMENT", O_RDONLY|O_CLOEXEC) = 3 newfstatat(3, "", {st_mode=S_IFREG|0644, st_size=23, ...}, AT_EMPTY_PATH) = 0 mmap(NULL, 23, PROT_READ, MAP_PRIVATE, 3, 0) = 0xffff8a4ff000 close(3) = 0 openat(AT_FDCWD, "/usr/lib/locale/C.UTF-8/LC_TELEPHONE", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory) openat(AT_FDCWD, "/usr/lib/locale/C.utf8/LC_TELEPHONE", O_RDONLY|O_CLOEXEC) = 3 newfstatat(3, "", {st_mode=S_IFREG|0644, st_size=47, ...}, AT_EMPTY_PATH) = 0 mmap(NULL, 47, PROT_READ, MAP_PRIVATE, 3, 0) = 0xffff8a4fe000 close(3) = 0 openat(AT_FDCWD, "/usr/lib/locale/C.UTF-8/LC_ADDRESS", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory) openat(AT_FDCWD, "/usr/lib/locale/C.utf8/LC_ADDRESS", O_RDONLY|O_CLOEXEC) = 3 newfstatat(3, "", {st_mode=S_IFREG|0644, st_size=127, ...}, AT_EMPTY_PATH) = 0 mmap(NULL, 127, PROT_READ, MAP_PRIVATE, 3, 0) = 0xffff8a4fd000 close(3) = 0 openat(AT_FDCWD, "/usr/lib/locale/C.UTF-8/LC_NAME", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory) openat(AT_FDCWD, "/usr/lib/locale/C.utf8/LC_NAME", O_RDONLY|O_CLOEXEC) = 3 newfstatat(3, "", {st_mode=S_IFREG|0644, st_size=62, ...}, AT_EMPTY_PATH) = 0 mmap(NULL, 62, PROT_READ, MAP_PRIVATE, 3, 0) = 0xffff8a4fc000 close(3) = 0 openat(AT_FDCWD, "/usr/lib/locale/C.UTF-8/LC_PAPER", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory) openat(AT_FDCWD, "/usr/lib/locale/C.utf8/LC_PAPER", O_RDONLY|O_CLOEXEC) = 3 newfstatat(3, "", {st_mode=S_IFREG|0644, st_size=34, ...}, AT_EMPTY_PATH) = 0 mmap(NULL, 34, PROT_READ, MAP_PRIVATE, 3, 0) = 0xffff8a4fb000 close(3) = 0 openat(AT_FDCWD, "/usr/lib/locale/C.UTF-8/LC_MESSAGES", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory) openat(AT_FDCWD, "/usr/lib/locale/C.utf8/LC_MESSAGES", O_RDONLY|O_CLOEXEC) = 3 newfstatat(3, "", {st_mode=S_IFDIR|0755, st_size=4096, ...}, AT_EMPTY_PATH) = 0 close(3) = 0 openat(AT_FDCWD, "/usr/lib/locale/C.utf8/LC_MESSAGES/SYS_LC_MESSAGES", O_RDONLY|O_CLOEXEC) = 3 newfstatat(3, "", {st_mode=S_IFREG|0644, st_size=48, ...}, AT_EMPTY_PATH) = 0 mmap(NULL, 48, PROT_READ, MAP_PRIVATE, 3, 0) = 0xffff8a4f1000 close(3) = 0 openat(AT_FDCWD, "/usr/lib/locale/C.UTF-8/LC_MONETARY", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory) openat(AT_FDCWD, "/usr/lib/locale/C.utf8/LC_MONETARY", O_RDONLY|O_CLOEXEC) = 3 newfstatat(3, "", {st_mode=S_IFREG|0644, st_size=270, ...}, AT_EMPTY_PATH) = 0 mmap(NULL, 270, PROT_READ, MAP_PRIVATE, 3, 0) = 0xffff8a4f0000 close(3) = 0 openat(AT_FDCWD, "/usr/lib/locale/C.UTF-8/LC_COLLATE", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory) openat(AT_FDCWD, "/usr/lib/locale/C.utf8/LC_COLLATE", O_RDONLY|O_CLOEXEC) = 3 newfstatat(3, "", {st_mode=S_IFREG|0644, st_size=1406, ...}, AT_EMPTY_PATH) = 0 mmap(NULL, 1406, PROT_READ, MAP_PRIVATE, 3, 0) = 0xffff8a4ef000 close(3) = 0 openat(AT_FDCWD, "/usr/lib/locale/C.UTF-8/LC_TIME", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory) openat(AT_FDCWD, "/usr/lib/locale/C.utf8/LC_TIME", O_RDONLY|O_CLOEXEC) = 3 newfstatat(3, "", {st_mode=S_IFREG|0644, st_size=3360, ...}, AT_EMPTY_PATH) = 0 mmap(NULL, 3360, PROT_READ, MAP_PRIVATE, 3, 0) = 0xffff8a4ee000 close(3) = 0 openat(AT_FDCWD, "/usr/lib/locale/C.UTF-8/LC_NUMERIC", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory) openat(AT_FDCWD, "/usr/lib/locale/C.utf8/LC_NUMERIC", O_RDONLY|O_CLOEXEC) = 3 newfstatat(3, "", {st_mode=S_IFREG|0644, st_size=50, ...}, AT_EMPTY_PATH) = 0 mmap(NULL, 50, PROT_READ, MAP_PRIVATE, 3, 0) = 0xffff8a4c7000 close(3) = 0 openat(AT_FDCWD, "/usr/lib/locale/C.UTF-8/LC_CTYPE", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory) openat(AT_FDCWD, "/usr/lib/locale/C.utf8/LC_CTYPE", O_RDONLY|O_CLOEXEC) = 3 newfstatat(3, "", {st_mode=S_IFREG|0644, st_size=353616, ...}, AT_EMPTY_PATH) = 0 mmap(NULL, 353616, PROT_READ, MAP_PRIVATE, 3, 0) = 0xffff8a2b9000 close(3) = 0 newfstatat(1, "", {st_mode=S_IFCHR|0620, st_rdev=makedev(0x88, 0x1), ...}, AT_EMPTY_PATH) = 0 openat(AT_FDCWD, "text.txt", O_RDONLY) = 3 newfstatat(3, "", {st_mode=S_IFREG|0644, st_size=5, ...}, AT_EMPTY_PATH) = 0 fadvise64(3, 0, 0, POSIX_FADV_SEQUENTIAL) = 0 mmap(NULL, 139264, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xffff8a297000 read(3, "hoge\n", 131072) = 5 write(1, "hoge\n", 5hoge ) = 5 read(3, "", 131072) = 0 munmap(0xffff8a297000, 139264) = 0 close(3) = 0 close(1) = 0 close(2) = 0 exit_group(0) = ? +++ exited with 0 +++
長くてよくわからないので見たほうが良さそうなシステムコールだけ抜粋します。
openat(AT_FDCWD, "text.txt", O_RDONLY) = 3
text.txt ファイルのオープン
text.txt ファイルが読み取り専用モード(O_RDONLY)でオープンされ、FD 3 が割り当てられます。
read(3, "hoge\n", 131072) = 5
ファイルの内容を読み取り
FD 3 から最大 131072 バイトを読み取ります。
write(1, "hoge\n", 5) = 5
標準出力に書き込み
読み取ったデータ(5 バイト)をFD1に書き込みます。
なぜFD1に書き込むとターミナル上に出力されるか疑問に思ったかもしれませんが、標準入力、標準出力、標準エラー出力のFDは以下のように予約されています。そのため、FD1に書き込んだ場合、標準出力としてターミナルがバイト列を解釈して表示するようになっています。
整数値 | 名前 |
---|---|
0 | 標準入力 (stdin) |
1 | 標準出力 (stdout) |
2 | 標準エラー出力 (stderr) |
「普通のLinuxプログラミング」という本にシンプルなcatコマンドの実装が載っています。この実装を見ると上記の流れでシステムコールを実行してストリームを操作していると思われます(まだ読んだだけでちゃんと動かしたり触ってはいないですが)
https://github.com/aamine/stdlinux2-source
パイプを用いたプロセス間通信を観察してみる
ファイルディスクリプタを用いることでプロセス間通信を行うことができます。
身近なプロセス間通信の例としてパイプがあります。パイプを使うことでプログラムの入出力の結果を別のプログラムの入力に渡すことができます。
cat test.txt | grep hoge
straceでパイプのシステムコールも観察してみます
パイプ周りのシステムコールの挙動をちゃんと理解できているか怪しいため、理解が違っていたらごめんなさい
strace -f -o log.txt bash -c 'cat text.txt | grep hoge'
パイプの挙動
pipe2([3, 4], 0) = 0
パイプが作成され、ファイルディスクリプタ3(読み取り)と4(書き込み)が割り当てられる
clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0xffffac5c10f0) = 309
子プロセスが生成される
catの挙動
read(3, "hoge\n", 131072) = 5
FD3でreadする
write(1, "hoge\n", 5) = 5
FD1に書き込み
grepの挙動
read(0, "hoge\n", 98304) = 5
catが行われた後、標準入力のFDからデータを受け取る
write(1, "hoge\n", 5) = 5
パターンマッチした行を標準出力のFDに書き込む
なぜ前のコマンドの標準出力をコマンドの標準入力に渡せているか、ということが疑問に思ったかもしれませんが、おそらく以下の2つの部分でFDの書き込み・読み取りをリダイレクトするような挙動をしているのかなと思っています。
dup3(4, 1, 0) = 1
dup3でおそらくcatの標準出力(FD1)がパイプの書き込み側(FD4)にリダイレクトしている?
dup3(3, 0, 0) = 0
dup3でgrepの標準入力(FD0)がパイプの読み取り側(FD3)にリダイレクトしている?
まとめると以下のような挙動によってプロセス間通信を実現できていると思われます。
- パイプを作成し、cat と grep の子プロセスを生成
- cat の標準出力をパイプの書き込み側にリダイレクト(dup3(4, 1, 0))
- grep の標準入力をパイプの読み取り側にリダイレクト(dup3(3, 0, 0))
- cat がファイルから読み込んだデータをパイプに書き込み(write(1, ...))
- grep がパイプからデータを読み取り(read(0, ...))
- パターンマッチしたデータを標準出力に書き込み
Socketでサーバ間のストリームの読み書きを行うことができる
パイプでファイルディスクリプタでどのようにプロセス間通信をしているかを観察してみました。パイプの場合、同一コンピュータ上でのプロセス間通信ですが、Socketを使うことで別々のコンピュータ同士がソケットのファイルディスクリプタを介したストリームの読み書きを行うことができます。
簡単に図にすると以下のようになっているかと思います。
サーバにリクエストされる際、Socketは以下のように動作します(Wikipedia参考)
- TCPソケットを生成(
socket()
呼び出し) - そのソケットを listen(コネクション確立要求待ち受け)ポート)にバインド(
bind()
呼び出し) - そのソケットをコネクションの listen に使用するため、
listen()
を呼び出す。 - 入ってきたコネクションを
accept()
呼び出しで受け付ける。これはコネクションが受信されるまでブロックし、受信したコネクションのソケット記述子を返す。最初の記述子は listen 用記述子のままであり、accept()
はそのソケットをクローズするまで何度でも呼び出せる(この際にTCPコネクションが確立されると思われる) - リモートホストと通信を行う。
send()
とrecv()
、またはwrite()
とread()
を使用する。
Socketを介した通信が行われると、ストリーム(ファイルディスクリプタ)にデータが書き込まれるので、サーバ側はストリームから読み取ったHTTPリクエストを処理して、HTTPプロトコルのルールに従ってストリームに書き込むということをすればWebサーバとして成立させることができます。
ソケットが低レイヤ(TCP)をいい感じに処理している部分は書くともっと長くなりそうなので、別の記事にまとめたいと思います。
サーバ側
python3 app.py
クライアント側
curl localhost:8800/
Hello, World!
マルチプロセス化する
反復モデルの問題点
検証のために2秒間スリープしてIOバウンドタスクをシミュレートするエンドポイントを追加します。
実装例:app2.py
import socket import time def view(raw_request): request_lines = raw_request.split('\n') first_line = request_lines[0] if request_lines else '' path = first_line.split(' ')[1] if ' ' in first_line else '/' # エンドポイントに応じてレスポンスを生成 if path == '/io': # 2秒間のI/O待機 time.sleep(2) return 'HTTP/1.1 200 OK\n\nThis is the /io endpoint after 2 seconds!' else: return 'HTTP/1.1 200 OK\n\nHello, World!' def main(): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(('127.0.0.1', 8800)) s.listen() print("Server is running on http://127.0.0.1:8800") while True: conn, addr = s.accept() with conn: raw_request = b'' while True: chunk = conn.recv(4096) raw_request += chunk if len(chunk) < 4096: break raw_response = view(raw_request.decode('utf-8')) conn.sendall(raw_response.encode('utf-8')) if __name__ == '__main__': main()
このエンドポイントは2秒弱でレスポンスが返ってきますが、1リクエストずつしかさばけないため、以下のように2並列でリクエストした場合、4秒かかってしまいます。反復モデルだと接続処理中に別の接続が来るとacceptが間に合わずどんどんリクエストが溜まっていってしまいます。
siege -c 2 -r 1 http://127.0.0.1:8800/io ** SIEGE 4.1.7 ** Preparing 2 concurrent users for battle. The server is now under siege... HTTP/1.1 200 2.01 secs: 41 bytes ==> GET /io HTTP/1.1 200 4.01 secs: 41 bytes ==> GET /io Transactions: 2 hits Availability: 100.00 % Elapsed time: 4.01 secs Data transferred: 0.00 MB Response time: 3010.00 ms Transaction rate: 0.50 trans/sec Throughput: 0.00 MB/sec Concurrency: 1.50 Successful transactions: 2 Failed transactions: 0 Longest transaction: 4010.00 ms Shortest transaction: 2010.00 ms
マルチプロセスでの処理方法
反復モデルでは逐一処理をするため、複数のリクエストを並行でさばけないので、マルチプロセスで並行処理するように実装します。
マルチプロセスでサーバを動作させる場合、従来はリクエストごとにforkして子プロセスを生成してリクエストを処理する方法が採用されていました。
リクエストに応じてプロセス数を動的に調整できることがメリットですが、この方法だと
- プロセスの起動コストが高い
- 応答時間が遅い
というデメリットがあるため、prefork workerモデルが使用されるようになりました。
prefork workerモデルは事前にプロセスをフォークして待機させ、リクエストが来ると既存のプロセスを使って処理するため、リクエストごとにforkをしなくて済みます。
prefork workerモデルで実装されているサーバ
prefork workerモデルで作ってみる
実装例:app3.py
from multiprocessing import Process import socket import time def view(raw_request): request_lines = raw_request.split('\n') first_line = request_lines[0] if request_lines else '' path = first_line.split(' ')[1] if ' ' in first_line else '/' if path == '/io': # 2秒間のI/O待機 time.sleep(2) return 'HTTP/1.1 200 OK\n\nThis is the /io endpoint after 2 seconds!' else: return 'HTTP/1.1 200 OK\n\nHello, World!' def handle_client(conn): with conn: raw_request = b'' while True: chunk = conn.recv(4096) raw_request += chunk if len(chunk) < 4096: break raw_response = view(raw_request.decode('utf-8')) conn.sendall(raw_response.encode('utf-8')) def worker_process(server_socket): while True: conn, addr = server_socket.accept() handle_client(conn) def main(): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(('127.0.0.1', 8800)) s.listen() print("Server is running on http://127.0.0.1:8800") processes = [] num_processes = 4 # 子プロセスを作成 for i in range(num_processes): p = Process(target=worker_process, args=(s,)) p.start() print(f"Worker {i + 1} has been created.") processes.append(p) # メインプロセスが子プロセスの終了を待つ try: for p in processes: p.join() except KeyboardInterrupt: print("Shutting down...") for p in processes: p.terminate() p.join() if __name__ == '__main__': main()
サーバ起動
python3 app3.py Server is running on http://127.0.0.1:8800 Worker 1 has been created. Worker 2 has been created. Worker 3 has been created. Worker 4 has been created.
プロセスを見ると4つの子ワーカープロセスができていることが分かります。(1つはリソーストラッカー)
**pstree 7784 -+= 07784 hoge python3 app3.py |--- 07788 hoge /Users/hoge/.local/share/mise/installs/python/3.12/bin/python3 -c from multiprocessing.resourc |--- 07790 hoge /Users/hoge/.local/share/mise/installs/python/3.12/bin/python3 -c from multiprocessing.spawn i |--- 07791 hoge /Users/hoge/.local/share/mise/installs/python/3.12/bin/python3 -c from multiprocessing.spawn i |--- 07792 hoge /Users/hoge/.local/share/mise/installs/python/3.12/bin/python3 -c from multiprocessing.spawn i \--- 07793 hoge /Users/hoge/.local/share/mise/installs/python/3.12/bin/python3 -c from multiprocessing.spawn i**
並列処理により、1リクエスト2秒の処理が2並列でも同じ2秒以内に完了することが確認できました!反復モデルでは4秒かかっていたことを考えると、大きな改善です。
siege -c 2 -r 1 http://127.0.0.1:8800/io ** SIEGE 4.1.7 ** Preparing 2 concurrent users for battle. The server is now under siege... HTTP/1.1 200 2.00 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.00 secs: 41 bytes ==> GET /io Transactions: 2 hits Availability: 100.00 % Elapsed time: 2.00 secs Data transferred: 0.00 MB Response time: 2000.00 ms Transaction rate: 1.00 trans/sec Throughput: 0.00 MB/sec Concurrency: 2.00 Successful transactions: 2 Failed transactions: 0 Longest transaction: 2000.00 ms Shortest transaction: 2000.00 ms
より効率的にリクエストを処理する方法
マルチスレッド
CPUバウンドな処理のみであればおそらくコア数分プロセスを作って処理するのが最も速いと思いますが、マルチプロセスは
- コンテキストスイッチのコストが高い
- メモリ使用量が大きい
というデメリットがあります。Webアプリケーションのタスクの多くはIOバウンドな処理によるものです。IOバウンドなタスクに対応するためにプロセス数を増やして対応するのは、システムのリソースを効率的に活用しているとは言えません(ある程度のトラフィック問題ないかもしれませんが)
マルチプロセスに対してマルチスレッドは
- コンテキストスイッチのコストが比較的低い
- メモリ使用量は少ない(スレッド間で共有するため)
という利点があります。Pythonの場合、GILの制限により、同一プロセス内で複数のスレッドが同時にPythonコードを実行することはできません。ただし、I/O待機中のスレッドが他のスレッドに処理を譲ることができるため、I/Oバウンドなタスクでは効率的に並行処理が可能です。
イベント駆動モデル
マルチスレッド、マルチプロセスにはC10K問題というものがあります。
この問題を簡単に説明すると、マルチプロセス・マルチスレッドで1台のサーバーで同時に10,000のクライアント接続を処理しようとすると、以下の理由から性能に問題が生じるということです。
- コンテキストスイッチのオーバーヘッド
- メモリ使用量が増える(マルチスレッドの場合はそこまででもないかも)
もちろんサーバを水平スケーリングすることで解消できますが、できるだけサーバ1台あたりの処理性能を上げてサーバ台数を減らしてコストを下げた方が望ましいです。
C10Kに対応する方法の一つとして、イベント駆動モデルがあります。イベント駆動モデルは、クライアントのアクセスをイベントとして扱い、それをトリガーにプロセス内で処理を行う方法です。イベント駆動モデルの特徴として、シングルスレッドでリクエストを並行して処理します。
通常の同期IOでは、一つのクライアントリクエストがIO操作を行っている間、そのスレッドやプロセスがブロックされて他のリクエストを処理できなくなりますが、イベント駆動の場合、非同期I/Oを使用することでIO操作の完了を待つ必要がありません。IOリクエストが完了すると、イベントループがその通知を受取、処理を再開するようになっています。そのため、IO待ちに影響されず並行してリクエストを処理することができるため、シングルスレッドでも高速に動作します。
イベント駆動モデルを採用したサーバーの例としてNode.jsやNginxが挙げられます。PythonのFastAPIはASGI仕様に基づいており、非同期I/Oを活用するフレームワークとして人気です。
https://qiita.com/kamihork/items/296ee689a8d48c2bebcd
イベント駆動で実際にシングルスレッド/プロセスで多数のリクエストをさばけるかを確認してみましょう。
pythonのasyncioで非同期サーバを立ち上げることができるので、これを使って検証してみます。
実装例:app4.py
import asyncio async def handle_client(reader, writer): data = await reader.read(4096) raw_request = data.decode('utf-8') request_lines = raw_request.split('\n') first_line = request_lines[0] if request_lines else '' path = first_line.split(' ')[1] if ' ' in first_line else '/' if path == '/io': # 非同期I/Oで2秒待機 await asyncio.sleep(2) response = 'HTTP/1.1 200 OK\n\nThis is the /io endpoint after 2 seconds!' else: response = 'HTTP/1.1 200 OK\n\nHello, World!' writer.write(response.encode('utf-8')) await writer.drain() writer.close() await writer.wait_closed() async def main(): server = await asyncio.start_server(handle_client, host='127.0.0.1', port=8800) print("Server is running on http://127.0.0.1:8800") async with server: await server.serve_forever() if __name__ == '__main__': asyncio.run(main())
サーバ起動
python3 app4.py Server is running on http://127.0.0.1:8800
100並列リクエストを並行して実行してみました。全体の処理時間はほぼ2秒に収まりました。マルチプロセスではプロセス数に依存してスループットが制限されますが、イベント駆動モデルはI/O待機中に他のリクエストを処理できるため、1スレッドでも多くのリクエストをさばくことができます。
siege -c 100 -r 1 http://127.0.0.1:8800/io ** SIEGE 4.1.7 ** Preparing 100 concurrent users for battle. The server is now under siege... HTTP/1.1 200 2.01 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.01 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.01 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.01 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.01 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.01 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.01 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.01 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.01 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.01 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io HTTP/1.1 200 2.02 secs: 41 bytes ==> GET /io Transactions: 100 hits Availability: 100.00 % Elapsed time: 2.02 secs Data transferred: 0.00 MB Response time: 2019.00 ms Transaction rate: 49.50 trans/sec Throughput: 0.00 MB/sec Concurrency: 99.95 Successful transactions: 100 Failed transactions: 0 Longest transaction: 2020.00 ms Shortest transaction: 2010.00 ms
イベント駆動の純粋なイベントループだけでは、1つのスレッドで処理を行うため、複数コアのCPUリソースを活用することはできません。CPUリソースを活用するには以下の2つの手法を組み合わせる必要があります。
Thread Pool + イベント駆動
- 非同期I/Oとスレッドプールを併用することで、I/Oバウンドタスクを効率よく処理しながら、一部のCPUバウンドタスクも並列に処理することができます。ただし、Pythonの場合、GILの影響があるため、CPUリソースをフル活用することができません。
Prefork + イベント駆動
- 起動時にCPUコア数分のワーカーを用意しておき、各ワーカーではイベント駆動モデルにすることでI/Oバウンドタスクを効率よく処理しながらCPUリソースを効率よく活用することができます。
Pythonの場合、prefork workerモデルと組み合わせるのが最もリソースを効率的に利用できる構成だと思います。例えばPythonのGunicorn・Uvicornサーバでは複数ワーカー・イベント駆動の構成を取ることができます。
まとめ
今回の記事では、反復モデルからイベント駆動モデルまでの進化について、自分なりに実装例を交えながらまとめてみました。運用しているアプリケーションの性質によってどの構成をとるのが適切かを判断し、より高いパフォーマンスを目指していきたいと思います。
また、今回のブログを書いていく中で、Webサーバをより深く理解するには、LinuxのOSの機能やネットワークAPIといった低レイヤーの知識が重要だと改めて実感しました。引き続き勉強を続けたいと思います。
最後に
最後まで読んでくださりありがとうございます!
この記事を読んで興味を持って下さった方がいらっしゃればカジュアルにお話させていただきたく、是非ご応募をお願いします。
iimon採用サイト / Wantedly / Green
次のアドベントカレンダーの記事はみよちゃんさんです! どんな記事を書いてくれるのか楽しみですね!!!!!
参考
SocketでHTTPサーバを建てる
Asyncio周り
サーバアーキテクチャ
書籍
ふつうのLinuxプログラミング 第2版 Linuxの仕組みから学べるgccプログラミングの王道 | 青木 峰郎 |本 | 通販 | Amazon