Programmer's High

epollインタフェースとsignalfd(2)

|
http://www.oreilly.co.jp/community/blog/images/union/pg_high_logo.png

>> (1)よりつづく

本記事のサンプルコードは、以下のリンクよりダウンロードすることができます。記事と合わせてご参照ください。
[ サンプルコード ]

子プロセスの同期/非同期

4epoll、5epoll-multiへのダミー処理追加には、いくつかの注意点があります。1baseと同じように、epollによるイベントループがその場で子プロセスの終了を待つようにすると、1つのダミー処理の終了を他のセッションが待つことになってしまいます。 これはepollによる、イベントループのI/Oの多重性を損なう大きな問題です( 図1.9 )。

図1.9 epoll の多重性を損ねるダミー処理追加

epoll2-fig9.png

(セッションCは割愛)

この問題はマルチスレッドを用いず、シングルスレッドで自らI/Oを多重化する構造に由来します。

epollによるイベントループを用いた構造では、子プロセスの終了を同期的に待ち合わせることはせず、親プロセスに対し送信されるSIGCHLDシグナルにより、子プロセスの終了を判断するようにし、 図1.8 と同様のインタリーブを実現する必要があります。

プロセス管理一般の話になりますが、親プロセスは通常、 wait(2) (またはそのファミリ)を発行し、子プロセスがまだ占有しているシステム資源を解放します。 wait(2) はたびたび誤解されることがあり、その機能は子プロセスの終了を「待つ」ことだけではありません。同期的に wait(2) を使用する場合は、待つことが主な目的であることが多く、実際何も問題はありませんが、終了したプロセスはシステム内から完全に居なくなるわけではなく、「ゾンビ」という状態で居座り続けます。一切動作せずプロセス内で使用した資源も解放済みですが、存在している以上、プロセスとして体をなす最低限のシステム資源を確保し続けます。このゾンビプロセスを完全に廃棄し、システム資源を解放するのも wait(2) の重要な機能です。言い換えるとその場で子プロセスの終了を待ち合わせない非同期の場合でも、 wait(2) は発行しなければなりません。

子プロセスをゾンビにさせない方法もありますが、本記事では取り扱いません。

epoll によるイベントループでは子プロセスの終了をその場では待ってはいけませんが、 wait(2) の発行を省略することもできません。省略するとシステムがゾンビプロセスだらけになってしまい、最後には新規プロセスを起動出来なくなってしまうでしょう。

少し話が逸れますが、現実には wait(2) が発行されない子プロセスが発生することは容易にあり得ます。プログラムミスもあるでしょうし、親プロセスが wait(2) する前に強制的に終了させられたなどは普通に起こり得ます。このためシステム側にもゾンビプロセス対策(対策と言っても特別のものではなく、通常機能の範囲です)があり、先に親プロセスが終了してしまった子プロセスは、強制的に init プロセスの子とし、 initwait(2) を発行し、ゾンビプロセスを廃棄します。

サンプルプログラムではダミー処理用に子プロセスを起動しますが、epollによるイベントループはその終了を非同期に検知し、 wait(2) を発行し、システムからゾンビプロセスを破棄することにします。この検知にはシグナルを用い、前記事でも簡単に触れておいた signalfd(2) を使用します。

signalfd

子プロセスが終了すると親プロセスにはシグナルSIGCHLDが送られます。 sigaction(2) によりシグナルハンドラを登録するのが従来より一般的な処理方法ですが、本記事ではepoll同様、Linuxに比較的新しく実装された signalfd(2) を用い、epollによるイベントループ内でシグナルを処理してみます。

epoll インタフェースには select(2) / pselect(2)poll(2) / ppoll(2) の関係同様に、シグナルマスクを指定できる epoll_pwait(2) も用意されていますが、本記事では取り上げません。

signalfd(2) はファイルの open(2) 同様に、ファイルディスクリプタを返します。このファイルディスクリプタを read(2) すると、シグナルが届いていれば、そのシグナルに関する詳細な情報を読み取れます。届いていなければ、届くまで read(2) は待ち続けます(ブロックする)。epollによるイベントループでsignalfdのファイルディスクリプタを見張り、読み取り可能を検知した時がシグナルが届いた時です。ここで wait(2) を発行すれば、ブロックすることはありませんし、必ず終了した子プロセスのプロセスIDが得られます。

しかし、この方式には注意が必要です。現代のシグナルはシステム内部で「保留」(suspend)されることがあります。シグナルハンドラは通常単純でごく短時間で完了する処理しか行いませんが(多くの処理をすべきではない)、この間に同じシグナルがもう1つ届いた場合には、システム側で保留し、シグナルハンドラ完了後に改めて届けてくれます。

この動作はシグナルを失わず信頼性を高めるものですが、すでにシグナルを保留している時に、さらにもう1つ同じシグナルが発生すると、保留せずに破棄します。つまり(シグナル種類ごとに)複数は保留しないという動作です。

一般にシグナルの宛先はプロセスでもスレッドでも良いのですが、2pthread-unlimited、3pthread-poolでは子プロセスを起動するのがスレッドのため、SIGCHLDはスレッドに届けられます。また同期的に待ち合わせるため保留されることもありません。

しかし、4epollはシングルスレッドで複数の子プロセスを起動します。さらにシグナルハンドラを用いず、signalfdをイベントループで見張るため、発生したSIGCHLDはすぐには処理されず、システム内でまず保留されます。多くのセッションを同時に処理する状態では、続くSIGCHLDが破棄されてしまうことはまず間違いないでしょう。

SIGCHLDは標準シグナルという種類ですが、リアルタイムシグナルという種類はすでに保留済みのシグナルが新たに発生しても破棄されず、順序も維持したまま届けられます。

シグナルの保留、破棄は理屈の上ではいつでもどこでも起こり得るものですが、通常のシグナルハンドラを登録する方式を用い、ハンドラ内の処理も少なければ、破棄される機会はずっと少なくなり、実際にはそれほどお目にかからないと思われます。言い換えるとsignalfdを見張るイベントループでは当り前のように破棄されてしまいます。

注意は必要ですが、この点については、「signalfdが読み取り可能になった時には wait(2) すべきゾンビプロセスが複数存在するかもしれない」ということを意識すれば、対応は困難ではありません。

epollでsignalfdを見張る場合の注意点はもう1つあります。signalfdから読み取れる情報(siginfo)は必ず読み出さなければならないことです。従来の sigaction(2) で登録するシグナルハンドラのパラメータを考えるとわかります。 struct sigactionsa_handler ではなく、 sa_sigaction の方です。パラメータには siginfo_t * があります。この情報はシステム側が領域を確保し、シグナルハンドラ実行後に破棄されるものです。

epollによるイベントループではシグナルハンドラを使用しないため、この情報はシステム側に留まったままとなり、これを参照可能にすると同時に破棄するのがsignalfdからの読み取りです。signalfdから情報を読み出してやらないと情報がシステムが領域を解放できず、また当然ながらepollがいつまでも読み取り可能を検知し続けてしまいます。

サンプルプログラムの改造では、signalfdが読み取り可能になる度に、signalfdからの読み出しを一度、その後 wait(2) を(その時点の)ゾンビプロセスが居なくなるまで何度でも実行することとします。

セッションソケットの状態遷移

以上の注意点を踏まえ、4epoll.cのどこへどのようにダミー処理を追加するかを考えます。epollによるイベントループでは、セッションソケットはもともと次のように処理されていました。

  1. acceptしたソケットをepoll対象へ加える
  2. 読み取り可能(HTTPリクエストが届いた)かを見張る
  3. 読み取り可能になった
  • HTTPリクエストを読み取り、
  • 対象ファイルパスを確認する
  1. 書き込み可能(送信できるか)を見張る
  2. 書き込み可能になった
  • 対象ファイルを送信する
  1. ソケットをepoll対象から外しcloseする

ダミー処理の追加位置は、リクエスト読み取り後、かつファイル送信前ですが、その間にイベントループが回ります。セッションソケットはすでにepoll対象に含まれており、そのままではepollがI/O可能かどうかを検知して、ダミー処理完了前にイベントループが処理してしまいます。このため、一時的にepoll対象から外すように変更します。

関連して、epoll に指定していたエッジトリガをレベルトリガへ戻します。

  1. signalfdを見張る
  2. acceptしたソケットをepoll対象へ加える
  3. 読み取り可能(HTTPリクエストが届いた)かを見張る
  4. 読み取り可能になった。
  • HTTPリクエストを読み取り
  • 対象ファイルパスを確認する
  • セッションソケットをepoll対象から外す
  • forkしダミー処理実行
  1. SIGCHLDが到着し、signalfdが読み取り可能になった
  • セッションソケットを再びepoll対象へ加える
  1. 書き込み可能 (送信できるか) を見張る
  2. 書き込み可能になった
  • 対象ファイルを送信する
  1. ソケットをepoll対象から外しcloseする

ソースコードの変更 (2)

4epoll.cへのダミー処理追加の内容が決定できました。いよいよ変更します。まず、新規関数 dummy_wait() を追加します。内容はsignalfdの読み出し、ゾンビプロセスの破棄、ソケットセッションの復帰です。

前述の 1base.cでは同期的に子プロセスの終了を待つため、 waitpid(2)option には 0を渡していましたが、こちらではいくつあるかわからない子プロセスを繰り返し waitpid(2) するため、 WNOHANG を渡します。

厳密には、 dummy_wait()wait(2) してもすでにゾンビプロセスが居なくなっている場合があり得ます。これは前回の dummy_wait() コールですべてのゾンビプロセスを破棄している際中にSIGCHLDが発生した場合に起こります。

wait(2) 発行はゾンビプロセスが居なくなるまで繰り返されますから、ループ中に終了した子プロセスはすぐに wait(2) されますが、siginfoはシステム内に保留されたままになります。これはsignalfdからの読み出しと複数回の wait(2) の順序に起因する状態で、余分な wait(2) 以外に問題はなく、実害はありません。

仮に複数回の wait(2) の後でsignalfdを読み出すようにすると、子プロセスの終了を検知できなくなる恐れがあります。

dummy_wait()要約

/* 子プロセスとセッションソケットの対応 */
int sock_child [];

dummy_wait(sigfd, epfd)
{
    /* siginfo 読み出し */
    read(sigfd);

    ev.events = EPOLLOUT;
    while (1) {
        /* 子プロセスの破棄 */
        pid = waitpid (pid, &status, WNOHANG);
        if (pid <= 0)
            break ;

        /* 子プロセスに対応するソケットを再び見張る */
        ev.data.fd = sock_child [ pid ];
        epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev);
    }
}

また、前述の dummy_busy() のイベントループ対応も加えます。

dummy_busy()のイベントループ対応要約 (2)

dummy_busy(sock, do_wait)
{
    pid = fork();

    if (pid > 0) {
        /* 親プロセス */
+       if (!do_wait)
+           /* イベントループの場合 */
+           /* セッションと子プロセスの対応を記録する */
+           /* 子プロセスの終了を待ち合わせずリターンする */
+           sock_child [ pid ] = sock ;
        else
            /* 子プロセスの終了待ち合わせ */
            waitpid (pid, &status, 0);
    } else

        /* 子プロセス --- ダミー処理 */
        exec "find /tmp > /dev/null 2>&1";
}

4epoll.c では signalfd(2) の発行とイベントループへの追加、および dummy_wait() コールを追加します。セッションソケットのepoll対象から外し、ダミー処理を起動するのは do_read() です。

4epoll.c へのダミー処理追加要約

do_read(sock, epfd)
{
    read(); /* HTTPリクエスト読み取り */
+   /* このソケットはもう見張る必要がない */
+   epoll_ctl(epfd, EPOLL_CTL_DEL, sock, NULL);

+   dummy_busy(sock, /* do_wait */0);
}

main ()
{
    /* リスニングポートへのデータ到着を見張る */
    epfd = epoll_create(1);
    ev.events = EPOLLIN;
    ev.data.fd = lsock;
    epoll_ctl(epfd, EPOLL_CTL_ADD, lsock, &ev);

+   /* SIGCHLDを見張る */
+   sigemptyset(&mask);
+   sigaddset (&mask, SIGCHLD);
+   sigprocmask(SIG_BLOCK, &mask, NULL);
+   sigfd = signalfd (-1, &mask);
+   ev.events = EPOLLIN;
+   ev.data.fd = sigfd ;
+   epoll_ctl(epfd, EPOLL_CTL_ADD, sigfd, &ev);

    while (1) {
        struct epoll_event e[];
        /* I/O可能なソケットを処理する */
        nfd = epoll_wait(epfd, e);
        for (i = 0; i < nfd; i++) {
            sock = e[i].data.fd ;
            if (sock == lsock)
                do_accept(sock, epfd);
+           else if (sock == sigfd)
+               dummy_wait(sigfd, epfd);
            else if (e[i].events &EPOLLIN)
                do_read (sock, epfd);
            else if (e[i].events & EPOLLOUT )
                do_sfc( sock, epfd );
        }
    }
}

5epoll-multi.cへのダミー処理追加は4epoll.cと基本的に変りません。

ただし、今回は測定環境がデュアルコアであり、4epoll.cと差をつけるために子プロセス数はCPU数に一致させました(前回は起動する4epoll相当の子プロセスをCPU数-1としていた)。上記の変更を加えたサンプルプログラムは、付属ソースコード一式に含めてあります。

測定/比較

ダミー処理を追加し、いよいよサンプルプログラムへ負荷を加え測定してみます。負荷生成/測定プログラムには比較的古典的な httperf を用い、同一ファイルをHTTP GETで連続して要求し、HTTPクライアントから見た応答性(1TCPコネクションの開始から終了まで)に注目します。また、HTTPサーバ側では同時に vmstat を起動し、HTTPサーバとして動作するサンプルプログラムの資源消費に注目します。付属ソースコード一式にはこの測定方法と結果も含めてあります。

■測定環境

  • 一秒間の要求数を指定し、十秒間動作させる
  • 要求するファイルサイズは 4KB
  • HTTP サーバ役 - Core2 Duo(x86_64)、3GHz - 4GB - plain linux-2.6.32.7
  • HTTP クライアント役は二台 - Celeron、3GHz - Pentium4、1.8GHz
  • 100M LAN

■測定結果の表

  • 最左列はHTTPクライアント役1台あたりの秒間要求数。2台あるのでHTTP サーバ役マシンに届く接続要求数はこの2倍
  • vmstat の列はサンプルプログラムと同時に HTTPサーバ役マシン上で実行した vmstat により二秒間隔で採取したシステム資源の消費状況からの最大値
  • httperf の列は測定プログラムの出力結果そのまま。標準偏差(stdev)はバラツキの大きさを表す。例えば、算術平均(avg)が1551.7msecと遅く見えても、半数のセッションは123.5msec(中央値、med)以内で完了しており、それほど遅くはない。この場合の標準偏差は35882.2と大きく、算術平均はごく少数の異常に突出した値により悪い方にひきずられたように見える。

1base

表1.2 測定結果―1base

  vmstat httperf
procs (r+b) mem (KB) CPU (%) 応答(msec)
min avg max med stddev err
200 1 2512 43 2.4 7.4 47.7 4.5 7.3 0
2.4 8.1 46.5 5.5 7.8 0
250 2 2824 51 2.4 76.0 3041.3 34.5 301.1 0
2.5 73.1 3040.6 36.5 277.2 0
300 1 3188 52 2.5 1162.7 11149.6 123.5 2488.1 64
8.7 1551.7 21005.7 123.5 3588.2 0

二台のHTTPクライアントそれぞれが秒間200件のHTTP GETを要求しても、1baseは充分に動作します。

CPU使用率は40%を越える程度で、メモリ消費もほとんど増加しません(シングルスレッド、逐次処理なので当然ですが)。応答性能も良好ですべて大半が数msec以内、最長でも数十msecで完了します。

負荷を秒間250件ずつ(クライアント役は二台なので、サーバ役に届くのは秒間500件)に増やすと、CPU使用率が50%に達し、応答にバラツキが出始めます。

シングルスレッドをデュアルコアシステムで動作させているので、CPU的には1つの限界にほぼ達したと言える状況です(厳密には人為的に加えたダミー処理が fork(2) しているため、CPU使用率が50%を越えてもおかしくありません)。大半のものは数十msec以内で終えていますが、最長で数秒もかかるものが発生しています(バラツキが大きい)。

負荷をさらに増やすと(秒間300件ずつ)この傾向が強まり、バラツキが拡がり、最長秒数も伸び、HTTPクライアントから見てエラーとなるセッションが発生し始めていますが、大半のものはまだ現実的な時間で応答を返しています。秒間250件ずつの場合と比較すると、増加した負荷に対しまだ余裕がある資源 (CPU、メモリ) を使いこなせておらず、応答性能の悪化だけに拍車がかかる状況が見て取れます。サーバ性能としてはすでに頭打ちと言えるでしょう。

2pthread-unlimited

表1.3 測定結果―2pthread-unlimited

  vmstat httperf
procs (r+b) mem (KB) CPU (%) 応答(msec)
min avg max med stddev err
200 5 2932 53 2.6 3.8 12.7 3.5 1.3 0
2.6 3.8 13.6 3.5 1.3 0
250 3 3344 66 2.6 4.6 20.3 3.5 2.3 0
2.6 4.8 22.2 4.5 2.5 0
300 153 100740 100 2.6 2061.1 12984.4 907.5 2817.4 0
2.7 2080.5 21001.2 919.5 2825.1 0

並列性が高いと言えば聞こえは良いかもしれませんが、負荷に比例しCPU/メモリとも多く消費します。応答性能は良好で、バラツキも少なく、秒間250件ずつまででは高速に動作しています。

秒間300件ずつ負荷を増やすと、スレッドを作成してもスケジューリングが間に合わず、 vmstat のprocsの数字が150を越えています。CPU上で実行される順番を待っているスレッド(およびダミーの子プロセス)が多数存在する状態で、CPU使用率は100%に達し、消費メモリも極端に増加しています。

これは1つの限界に達している状態であり、クライアントから見た応答性能も極端に悪化し、平均値でも2秒を越え、最長では20秒にも達し、破綻とも言える状況です。HTTPクライアントにエラーこそ返されませんが、1つの限界に達していると言えます。

3pthread-pool

表1.4 測定結果―3pthread-pool

  vmstat httperf
procs (r+b) mem (KB) CPU (%) 応答(msec)
min avg max med stddev err
350 2 3188 79 2.5 6.1 30.7 4.5 4.8 0
2.5 6.3 30.2 4.5 5.0 0
400 2 3600 90 2.5 15.8 3035.8 5.5 107.8 0
2.5 14.3 3033.4 6.5 69.6 0
450 2 3808 100 2.6 99.5 9001.9 32.5 496.2 0
2.5 80.6 9002.7 31.5 464.9 0
500 3 4312 100 3.0 864.2 9319.8 73.5 1824.5 0
3.1 920.6 21002.3 73.5 1998.7 0

1base、2pthread-unlimited よりも良好な結果です。2スレッドで1baseを実行している状態なので、単純に1baseの倍の性能を期待したくなりますが、今回の測定ではもっと早く限界に達したようです。

秒間350件ずつまでの負荷では良好ですが、秒間400件ずつになるとCPU使用率は90%に達し、3秒を越えるセッションが発生し始めます。 これが限界かと思いきや、秒間450件ずつに上げてもエラーは発生せず、大半のセッションはやはり数十msec以内で応答を返しています。

ただし、CPUは100%使い切っており、最長セッションは九秒にもなります。さらに負荷を上げてもこの傾向は変らず最長セッションの悪化幅が拡がります。2pthread-unlimited と比較すると、当然ながら、メモリ消費はずっと少なく済んでいます。

簡単にまとめると、少なくとも今回の測定では、性能面では2pthread-unlimitedに、安定性は1baseにそれぞれ匹敵し、それでいて上限はもっと高いと言えます。

4epoll

表1.5 測定結果―4epoll

  vmstat httperf
procs (r+b) mem (KB) CPU (%) 応答(msec)
min avg max med stddev err
350 5 3740 79 2.4 9.2 62.1 5.5 8.8 0
2.5 9.7 61.4 5.5 9.3 0
400 27 5508 91 2.5 19.3 3037.2 9.5 52.4 0
2.5 19.4 109.1 10.5 21.5 0
450 21 6500 100 2.5 83.5 8999.8 44.5 348.4 0
2.5 93.5 9008.9 44.5 453.5 0
500 28 7260 100 5.0 649.4 9118.1 98.5 1572.1 0
3.0 608.2 9137.7 97.5 1493.2 0
550 25 6988 100 3.0 1225.9 9123.3 105.5 2280.7 5
6.8 1312.3 21006.0 105.5 2565.8 0

4epollはシングルスレッド構成ですが、I/Oを多重化しているため複数のセッションを同時に処理します。本来はCPU使用率が50%を越えることはない構成ですが、ダミー処理を追加しセッションごとに子プロセスを生成しているため、今回の測定では50%を簡単に越えています。

測定結果は3pthread-pollに良く似ています。秒間350件ずつまでは良好、秒間400件ずつになると遅いセッション (最長で3秒を越える) ものが出始めます。秒間450件ずつでは九秒に達します。良好な応答をしている場合も、応答時間の平均値は3pthread-pollよりも若干長くなっており、またスケジューリングの待ち行列も長めになっています。これは同期的に待ち合わせないための処理がオーバヘッドになっていると考えられます。

負荷を上げていくと応答性能などは当然悪化しますが、3pthread-pollと比較すると悪化幅はやや小さく、負荷に対してはより強い傾向がうかがえます。

5epoll-multi

5epoll-multiは動作環境として、もっと多くのCPU数を想定しているため、今回の測定/比較は理想的ではありませんが、現実にはそうも言っていられません。

構造的には 4epollを2つ(本来はCPU数 - 1)起動した形になりますが、 accept(2) は別プロセスで処理するため、その分のオーバヘッドがあります。このオーバヘッドは負荷が秒間350件ずつの場合から見て取れます。

測定結果も4epollに良く似ていますが、耐負荷性能という点ではさらに上がっている傾向がうかがえます。オーバヘッドのため、最短セッション秒数は4epollよりも長く(悪く)なっていますが、最長セッションの方は短くなっています。秒間350件ずつの場合で4epollが9秒に達しているのに対し5epoll-multiでは350msec 程度となっており、バラツキも少なくなっています。

秒間500件ずつの場合を4epollと比較すると、4epollの最短セッション秒数が悪化し5epoll-multiと同程度になり、中央値も延び、バラツキが拡がるのに対し、5epoll-multiの最短セッション秒数は悪化せず、中央値の悪化幅は小さく、最長セッション秒数の優位は保っています。

簡単にまとめると負荷が低い場合にはオーバヘッドのため4epollよりも時間がかかるが、高負荷に対しては耐性が期待できると言えます。

表1.6 測定結果―5epoll-multi

  vmstat httperf
procs (r+b) mem (KB) CPU (%) 応答(msec)
min avg max med stddev err
350 6 4572 82 2.5 11.0 103.7 5.5 13.7 0
2.5 11.6 80.4 5.5 14.1 0
400 21 6088 94 2.5 23.8 181.7 10.5 30.2 0
2.5 24.7 181.9 11.5 30.6 0
450 21 8592 100 2.5 120.2 349.8 110.5 76.1 0
2.5 121.5 348.1 110.5 76.4 0
500 19 15612 100 3.0 672.1 1160.4 746.5 306.7 0
3.2 675.5 1141.6 751.5 305.6 0
550 46 21160 100 3.9 1484.2 9123.7 810.5 1963.2 16
2.9 1485.1 9128.6 808.5 1970.4 17
600 45 18428 100 14.6 1608.2 9236.1 747.5 2271.9 526
2.9 1599.2 9238.8 743.5 2274.6 511

おわりに

測定結果について補足しておきます。応答にバラツキがあり、数秒もかかるものがあるからと言っても、それだけでそのサーバがまるでダメとは言い難いものがあります。

これは考え方次第かもしれませんが、大半のものが設定した上限、例えば1秒以内に応答が返され、ごく少数のものだけがそれを越えるという状況ならば、許容できるかもしれません。言い方を替えると、設定した上限に100%収まることが条件か、それとも95%ならば合格とするのかということになると思います。100%を条件とすると、サーバ台数など過剰に設備投資することになり、通常状態では多くの設備が遊んでしまうという無駄を生みかねません。

前記事の内容を確認する意味もあり、今回改めて測定してみました。結果はおおむね予想通りですが、ダミー処理の追加など新たな技術的テーマも追加しています。マルチスレッドを否定するわけではありませんが、高性能を期待する場合は epoll によるイベントループの方が適切な場合も考えられ、読者のソフトウェア開発において、前記事、本記事が一助となれば幸いです。

(初出: H22/2)

Copyright © 2010 SENJU Jiro

本稿で取り上げた内容は、記事の執筆時の情報に基づいており、その後の実装の進展、変更等により、現在の情報とは異なる場合があります。 本連載「プログラマーズ・ハイ」では読者からのご意見、ご感想を切望しています。今後取り上げるテーマなどについてもご意見をお寄せください。

アーカイブ