[2022.11] ページが長くなりすぎ。割った。
Note.
(2006.08.05 追記) (2022.04 追記)
下記のコードでは connect()
, read()
をnon-blockingモードにしている。しかし、getaddrinfo()
も, ホスト名の解決を行うために時間が掛かる (ブロックする)。この考慮が足りない。
glibc 拡張の getaddrinfo_a()
, Qt の QDnsLookup
クラスは非同期だが、いずれも内部でスレッドを生成している。内部で getaddrinfo()
を使う限り、このような実装しかない。libresolv の res_nsearch()
(res_search()
は非推奨.) も応答を待つ。
Ruby では、自前のリゾルバ Resolv::DNS
クラスを作っている。すごい。ここまでやれば DNS lookupを非同期にできる。できるが、やりすぎ。
ということで、非同期 asynchronous I/O にするには, 自分でスレッドを生成して、getaddrinfo()
, ブロッキングモードの connect()
を呼び出してサーバに接続するようにすべき。Windowsでも同様の方法が推奨されている。(WSAAsyncGetHostByName
の頁を参照。) もっとシンプルにできるだろう。
別ページで、Qt クライアントサンプルを解説する。ソケット通信はブロッキングモードにする。
余談。Qt はマルチスレッドでのワーカースレッド・メインスレッド間のメッセージのやり取りがシグナル/スロットを使うだけでよく、非常に簡単なので、わざわざ作るのが難しい non-blocking モードのソケットにするメリットもない。
他方、GTKは、Qtを真似したシグナルがスレッドを跨げないので、難儀する。gtkmm は Glib::Dispatcher
クラスで, わざわざパイプを生成してスレッド間通信している。意外な機能で g_idle_add()
, g_idle_add_full()
が、名前と全然関係なく、メインスレッドでコールバックを呼び出してくれるので、これを使う。GTK4 になって非互換ばかりで, ますます無茶苦茶になっている。生産性も低く、もう実用にならない。
Windows では GetAddrInfoExW()
(Unicode版のみ!) が非同期に対応している。が、サンプルが全然見当たらない。
(以下、2000.10.7, 2000.10.8の日記を加筆。)
ソケット関係の関数は、connect()
, read()
, サーバ側の accept()
と、どれもこれもブロックする。ネットワーク越しのデータのやり取りは本質的に非同期なので、平行して入力がある場合、いちいちブロックするようにプログラムを書いてはいけない。
例えば、GUIプログラムの場合は、ソケットをブロックしていたら、マウスのクリック、画面の再描画などを受け付けられず、フリーズしてしまう。
ではどうするか。
- マルチスレッドにして, ソケットは blocking モードで接続, 通信する. 今どきはこれが簡単.
connect()
だけ non-blocking モードにして, 接続する。接続できたら blocking モードにして, select()
または poll()
で待ち合わせる.
- ずっと non-blocking モードで接続, 通信する.
ここでは、フレームワークが用意したコールバックの仕組みを使ってみる。どのようなフレームワークでも、ソケットに限らずさまざまな入力を捌く機構が用意されているので、それを使う。そうすれば、ソケットの入力を待ちながら画面を更新したりできるだろう。
Fedora Core 5 と gtk+ 2.8.20 という環境で試してみた。全体的にやっつけで作っているので、実用にしようと思うと、まだ考えるべきところが多いので注意。
[2022.11] GTK4 に更新。修正を要する非互換が多く, ヒドい.
Non-blocking ソケット
UNIXでは、ソケットに限らず、ファイルIOをブロックしないように設定できる。open()
または fcntl()
で O_NONBLOCK
フラグを設定すると、non-blocking モードになる。ソケットでは, 後者の fcntl()
関数の F_SETFL
コマンドで O_NONBLOCK
フラグを設定できる。
Non-blockingモードでは、read(2)
, write()
, send()
, recv()
関数は、ブロックせずに、すぐに返ってくるようになる。戻り値とエラーコードで状況を調べる必要がある。EAGAIN
または EWOULDBLOCK
エラーが発生する。
connect()
関数もすぐに返ってくる。EINPROGRESS
エラーになるかを確認する。
accept(2)
も同様。listenキューが空でも、すぐに返ってる。EAGAIN
または EWOULDBLOCK
エラーが発生する。
EAGAIN
と EWOULDBLOCK
エラーは, 異なる値かもしれず、かつどちらのエラーもありうる。Linuxでは同じ値になっているが、移植性のため、両方を確認すること。
何を作るか
ごく簡単なGTK4 クライアントを作る。サーバから fortune テキストを得て、表示するだけ。
GTK アプリケーションの部分は、本題ではないので、割愛する。
接続要求関数
では、接続要求を投げるところから順に作っていこう.
Non-blocking モードの設定
ソケットを non-blocking モードにするには fcntl()
を使う。F_GETFL
コマンドで現在のフラグを読み出し、F_SETFL
で設定する。
C++
-
-
-
-
- int non_blocking( SOCKET sock, int mode )
- {
- assert( sock != INVALID_SOCKET );
- #ifndef _WIN32
-
- int oldflags = fcntl(sock, F_GETFL, 0 );
- return fcntl(sock, F_SETFL,
- mode ? (oldflags | O_NONBLOCK) : (oldflags & ~O_NONBLOCK) );
- #else
-
- u_long iMode = mode ? 1 : 0;
- int iResult = ioctlsocket( sock, FIONBIO, &iMode);
- if ( iResult != NO_ERROR )
- return -1;
- return 0;
- #endif
- }
接続要求
getaddrinfo()
が複数のソケットアドレスを返す. 通常は IPv6 アドレスと IPv4 アドレスの二つ。両方に対して接続要求を出すので、複雑になる.
Non-blocking ソケットでは、connect()
がすぐに返ってくる。サーバがIPv4対応かIPv6対応か分からない状況では、ソケットを2本使う必要がある。両方でconnectを試みて、接続できたほうを生かす。
C++
-
-
-
- bool start_connect_to_server_nonblock(const char* hostname, int port)
- {
- assert( hostname );
- if (port <= 0 || port > 65535 )
- return false;
-
- struct addrinfo hints;
- memset(&hints, 0, sizeof(hints));
- hints.ai_family = AF_UNSPEC;
- hints.ai_socktype = SOCK_STREAM;
- hints.ai_flags = AI_NUMERICSERV;
-
- char service[11];
- sprintf(service, "%d", port);
-
- struct addrinfo* res = NULL;
-
- int err = getaddrinfo(hostname, service, &hints, &res);
- if (err != 0) {
- fprintf(stderr, "getaddrinfo() failed: %s\n", gai_strerror(err));
- error_dialog("ホストまたはポートが不正です。");
- return false;
- }
冒頭 Note. のとおり, getaddrinfo()
がブロックしてしまうため、このサンプルは傷がある。無理やりいくなら、ここだけマルチスレッドにする。
connect()
はブロックされず、EINPROGRESS
エラーを発生させる。それを確認して、メインループでイベントを待ち合わせる.
接続に成功すると書き込み可能状態 POLLOUT
になるので,そのようにコールバック関数を登録する。start_watch()
関数でおこなっている。
C++
- SOCKET sockfd = INVALID_SOCKET;
- bool succeeded = false;
- for (addrinfo* ai = res; ai; ai = ai->ai_next) {
- sockaddr_print("connect...", ai->ai_addr, ai->ai_addrlen);
-
- sockfd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
- if ( sockfd == INVALID_SOCKET ) {
- freeaddrinfo(res);
- return false;
- }
-
- non_blocking(sockfd, 1);
- int r = ::connect(sockfd, ai->ai_addr, ai->ai_addrlen);
- if (r < 0) {
- if (errno == EINPROGRESS || errno == EINTR ) {
-
-
-
- pair<GIOChannel*, guint> ev =
- start_watch(sockfd, G_IO_OUT, on_connect, NULL);
- wait_connect_events.insert(ev);
-
- succeeded = true;
- continue;
- }
- closesocket(sockfd);
- sockfd = INVALID_SOCKET;
- continue;
- }
- else {
-
- on_connect(NULL, G_IO_OUT, NULL);
- return true;
- }
- }
- freeaddrinfo(res);
-
- return succeeded;
- }
GTK でコールバック関数を登録する start_watch()
関数を見ておこう。
C++
- pair<GIOChannel*, guint>
- start_watch(SOCKET sockfd, int condition, GIOFunc proc, gpointer user_data)
- {
- assert(sockfd != INVALID_SOCKET);
-
-
- GIOChannel* channel = g_io_channel_unix_new(sockfd);
- guint io_watch = g_io_add_watch_full(channel, G_PRIORITY_DEFAULT,
- (GIOCondition) condition,
- proc,
- user_data,
- on_io_destroy);
- assert(io_watch);
-
- g_io_channel_unref(channel);
-
- return make_pair(channel, io_watch);
- }
gdk_input_add()
関数が廃止され, g_io_add_watch_full()
を使う. 代替関数なしにドンドコ deprecated にしていくのは止めてほしい。ファイルディスクリプタをそのまま登録するのではなく, GIOChannel
で wrap する.
余談. g_io_channel_unix_new()
の引数の型が int
決め打ちで、64bit Windows では動かない. 何のために wrap したのか意味不明. GPollFD
型は 64bit Windows では64bit ハンドルになっているので、修正漏れか.
接続成功 → イベント登録
接続に成功したら,読み込み可能状態でコールバックしてもらうように登録し直す。あとはコールバックされたら read()
すればよい。
C++
-
-
- gboolean on_connect(GIOChannel* source, GIOCondition condition, gpointer data)
- {
- print_condition(__func__, condition);
-
- if ( condition & (G_IO_ERR | G_IO_HUP) ) {
-
- SOCKET fd = g_io_channel_unix_get_fd(source);
- closesocket(fd);
-
- wait_connect_events.erase(source);
- if (wait_connect_events.size() == 0) {
- error_dialog("接続に失敗しました。");
- g_idle_add(on_connect_error, (void*) -1);
- }
- return FALSE;
- }
-
- assert( wait_connect_events.size() > 0 );
-
-
- for (auto i = wait_connect_events.begin();
- i != wait_connect_events.end(); i++) {
- if (i->first == source)
- continue;
- SOCKET fd = g_io_channel_unix_get_fd(i->first);
- closesocket(fd);
- g_source_remove(i->second);
- }
- wait_connect_events.clear();
-
-
- SOCKET sockfd = g_io_channel_unix_get_fd(source);
- g_connection = start_watch(sockfd, G_IO_IN, on_ready_to_read, NULL);
-
- return FALSE;
- }
複数の接続要求を同時に走らせているので、接続できた場合はそれ以外の全部、接続できなかった場合はそのソケットを閉じていく。不必要に複雑になっている。
読み取って表示
EOF の場合は, エラーではなく, 読み取り可能かつ recv()
の戻り値が 0 になる。
C++
-
- gboolean on_ready_to_read(GIOChannel* source, GIOCondition condition,
- gpointer data)
- {
- print_condition(__func__, condition);
-
- if ( condition & (G_IO_ERR | G_IO_HUP) ) {
- error_dialog("読み込み中にエラー発生");
- close_and_notify(false);
- return FALSE;
- }
-
- if (condition & G_IO_IN) {
- SOCKET fd = g_io_channel_unix_get_fd(g_connection.first);
- char buf[1000];
- int bytes_read = recv(fd, buf, sizeof(buf) - 1, 0);
- if (bytes_read < 0) {
- perror("recv() failed");
- error_dialog("recv() でエラー発生");
- close_and_notify(false);
- return FALSE;
- }
- else if ( bytes_read == 0 ) {
-
- close_and_notify(true);
- return FALSE;
- }
-
- assert( bytes_read > 0 );
- buf[bytes_read] = '\0';
- GtkTextBuffer* buffer = gtk_text_view_get_buffer(top_window->textView);
- assert(buffer);
- gtk_text_buffer_insert_at_cursor(buffer, buf, bytes_read);
- }
-
- return TRUE;
- }
read()
は次のいずれかで返る。
- 引数として与えたバッファが埋まった
- 書き込み側のwrite()の切れ目
- 書き込み側でソケットが閉じられた
上記の 2. から, バッファのバイト長より読めた長さが短くても、ソケットが閉じられたとは限らない。同様にread()の戻り値がバッファのバイト長と同じでも,まだデータがあるとは限らない。
ブロッキングモードの場合, read()
の戻り値がたまたまバッファのバイト長と同じで、まだデータがあると思ってさらに read()
するとブロックしてしまう。
一回だけ read()
したら読み込みのルーチンを終了し,メインループに戻すようにする。メインループの待ち合わせでデータがあるかどうかを検査し、また読み込みルーチンを呼び出してもらう。
開始ボタン
あとは、開始ボタンのハンドラ。マルチスレッド版のコードと共用。
callbacks.cpp
C++
-
- void on_get_button_clicked(GtkButton* button, gpointer user_data)
- {
- #ifdef USE_THREAD
- if (worker_thread)
- return;
- #endif
-
-
- GtkEntryBuffer* b = gtk_entry_get_buffer( GTK_ENTRY(top_window->hostEntry) );
- worker_param.hostname = gtk_entry_buffer_get_text(b);
- b = gtk_entry_get_buffer( GTK_ENTRY(top_window->portEntry) );
- const char* port_str = gtk_entry_buffer_get_text(b);
- worker_param.port = atoi(port_str);
- if ( worker_param.port <= 0 || worker_param.port > 65535 ) {
- GtkDialog* dialog = GTK_DIALOG( gtk_message_dialog_new(top_window->self,
- GTK_DIALOG_DESTROY_WITH_PARENT,
- GTK_MESSAGE_ERROR,
- GTK_BUTTONS_CLOSE,
- "Port error") );
- g_signal_connect(dialog, "response",
- G_CALLBACK(gtk_window_destroy), NULL);
-
-
- gtk_widget_show( GTK_WIDGET(dialog) );
- return;
- }
-
- #ifdef USE_THREAD
-
-
- GError* error = nullptr;
- worker_thread = g_thread_try_new("worker", start_get_fortune, &worker_param,
- &error);
- if ( !worker_thread ) {
- g_printerr("Error creating thread: %s\n", error->message);
- g_clear_error(&error);
- return;
- }
- #else
- if (!start_connect_to_server_nonblock(
- worker_param.hostname.c_str(), worker_param.port)) {
- return;
- }
- #endif
-
- gtk_widget_set_sensitive( GTK_WIDGET(top_window->hostEntry), FALSE );
- gtk_widget_set_sensitive( GTK_WIDGET(top_window->portEntry), FALSE );
- gtk_widget_set_sensitive( GTK_WIDGET(button), FALSE );
- }
関数をいきなり廃止したりするの、本当に止めてほしい。