週刊アスキー

  • Facebookアイコン
  • Twitterアイコン
  • RSSフィード

Goで覗くシステムプログラミングの世界

2016年09月21日 20時00分更新

 プログラミングの勉強にあたってよく言われるのは、「流行に左右されるような技術の尻を追いかけるよりも、土台となる技術を身につけることが大切」ということです。 例えば、ウェブブラウザで動くJavaScriptを書くときは、流行しているライブラリの書き方を暗記するよりも、 ブラウザがどのようにCSSやHTMLを解釈してスクリーンに文字や絵を描き出していく(レンダリングしていく)のかを理解することが大切です。 さもないと、ライブラリの流行が変わるだけで勉強したスキルが失われてしまいかねません。 データベースでも同じことがいえます。SQLの文法を学ぶことよりも、データベースがどのようにスケジューリングを行い、 どのようにデータを探索していくのかを学ぶほうが、パフォーマンス・チューニングのコツなどもひらめきやすくなるでしょう 1

 「土台となる技術を身につける」を、もう少しちゃんと言い換えれば、「今の関心領域より下のレイヤーの知識も身につけよう」ということになります。 みなさんが使っている言語の下のレイヤーはなんでしょうか? Ruby、Python、PHPなど、多くのスクリプト言語はC言語で書かれています。node.jsはC++ですね。 ということは、これらの言語についてはC言語かC++が下のレイヤーといえるでしょう。

 それでは、C言語やC++のさらに下のレイヤーはなんでしょうか?よく言われるのはアセンブリ言語ですが、じつはアセンブリというのはC言語やC++より下のレイヤーのうちの半分でしかありません。 残りの半分は、オペレーティングシステム(OS)です。 つまり、プログラマーとして土台となる技術を身につけようと思ったら、OSの機能がプログラミング言語でどう使われるかも知っているべきだということです。

 例えば、ファイルを開くのも、メモリを確保するのも、ネットワークにアクセスするのも、すべてOSが提供する機能を利用します。 本連載では、OSが開発者にどのような機能を提供してくれているかを見て、それらを使う「システムプログラミング」の方法を学びます。 プログラミングを支えている下位のレイヤを、 プログラマの視点で 知ることが、本連載の目的です。

 この記事では、その第1回として、解説に使うプログラミング言語である Go の開発環境を準備し、 デバッガーを使ってシステムプログラミングのレイヤを覗いてみます。 開発環境を準備してデバッガーの使い方に慣れるまで、最初のうちは少し戸惑うところがあるかもしれませんが、 何かしらの言語でプログラミングをしたことがあれば読める内容を目指します。

システムプログラミングとは

 今日行われるプログラミングの多くはウェブに関係しています。 ウェブサービスのUIを動かすJavaScriptだけでなく、バックエンドのウェブアプリケーションサーバであったり、 ウェブシステムが出力するログを収集してきて機械学習させるシステムだったり、多岐にわたるプログラミングがウェブに関係しています。

 そうしたウェブ関係のプログラミングとは対照的な場面でよく使われているのが、システムプログラミングという用語です。 実際、システムプログラミングとは何でしょうか? 人によって定義がいろいろ異なりますが、よく見かけるのは次のような内容を指して「システムプログラミング」という場合です。

  • C言語によるプログラミング
  • アセンブリ言語を意識したC言語によるプログラミング
  • 言語処理系(インタプリタを含む)、特にネイティブコードを生成するコンパイラの開発
  • OS自身のプログラミング
  • OSの提供する機能を使ったプログラミング

 本連載では、一番最後の「OSの提供する機能を使ったプログラミング」をシステムプログラミングの定義として話をすすめます。

OSの機能について

 みなさんが使っているOSは、グラフィカルなインタフェースを備えているものが多いでしょう。 モバイル端末のOSには、地図情報を利用するための機能やカメラのための機能がついています。 ニュースを見ていると、OSの機能として、テレビ電話とか人工知能AIなどの言葉が踊っていることもあります。

 しかし、現在のコンピュータにOSの一部としてインストールされているこれらの機能の多くは、実は「アプリケーション」です。 地図機能がないモバイル端末は、ビジネス上は不利でしょうが、歴史上は地図が見られないOSのほうが多数派です。 グラフィカルなユーザインタフェースも、サーバ系のOSであれば持っていないことがあります。 これらはアプリケーションであり、機能上はなくてもOSは成立します。

 現在の一般的なコンピュータに搭載されるOSについて、その機能の最大公約数を取れば、次のようなものが「OSの機能」だといえるでしょう。

  • メモリの管理
  • プロセスの管理
  • プロセス間通信
  • ファイルシステム
  • ネットワーク
  • ユーザ管理(権限など)
  • タイマー

 プログラミングを支えている下位のレイヤを知る、という本連載の目的を言い換えれば、これらのOSの機能を学ぶということになりそうです。 しかし、これらの機能をボトムアップで勉強していくと、「自分のプログラミングのレベルアップにどうつなげればいいか分からない」という状態になりがちです。 それにボトムアップな解説は読んでいて眠くなります。

 この連載では、これらのOSの機能を、もっとプログラマーになじみやすい視点で、普段の開発にもフィードバックできるように見ていくことにします。 そこで使うのがGo言語です。

Go言語

 本連載でシステムプログラミングに使うのは、Go言語です。 Go言語は、C言語の性能とPythonの書きやすさ/読みやすさを両立させ、 モダンな言語の特徴をうまく取り入れた言語となることを目標にGoogleが開発したプログラミング言語です 2

 ただし、連載を読むのにGo言語をバリバリ書ける必要はありません。 JavaやRubyやPython、PHPなどの何らかの言語を使ったことがあり、Goに少し興味がある人が対象読者です。 Goの文法をゼロから説明することはしませんが、必要になったときに随時紹介していく予定です。

 なぜ解説にGo言語を使うことにしたかというと、Go言語は多くのOSの機能を直接扱えて、なおかつ少ない行数で動くアプリケーションが作れるからです。 現在のシステムプログラミングでは主にC/C++が使われていますが、 Go言語はC/C++よりもコードを書き始める前のライブラリの収集が簡単です。 C/C++のように道を踏み外して地雷が爆発するようなことも、ガベージコレクションのおかげでメモリ管理を手動で頑張る必要もなく、 コーディングに集中しやすくなっています。 コンパイルも早く、エラーメッセージもわかりやすくなっています。 もちろん、直接システムコールを呼び出したり(LinuxやBSD、macOSなどのPOSIX系)、OSの提供するAPIを呼び出したりできる(Windows)ので、 システムプログラミングを学ぶうえでなんの問題もありません。

Go言語はC言語を置き換えるか

 Go言語でシステムプログラミングをすることは可能ですが、今後、Go言語がC/C++の役割を置き換えられるかというと、筆者はそうは思いません。 Go言語はスクリプト系言語よりは遥かに高速であるとはいえ、C/C++と比べると性能は落ちますし、バイナリサイズもかなり大きくなります。 2016年8月にリリースされたばかりのGo言語の1.7では速度もサイズも改善はされたものの、今後すぐにGo言語がC/C++を置き換えていくとは考えられません。 本連載では、OSを書くような言語にはならないものの、OSの機能を気軽に使え、高度に抽象化されすぎない簡単に書けるC言語という前提で、Go言語を取り扱っていきます。

 このあたりについて詳しく知りたい方は、D言語の作者のアンドレイ・アレキサンドレスク氏が「将来C言語を置き換えることになる勝者に一番近い言語は何か?」 という質問に答えたQuoraの回答エントリー 3 も参考になるでしょう。C++/D言語/Go/Rustについて語られています。

Go言語のインストールと準備

 ここでは、Go言語と統合開発環境であるIntelliJのインストールと設定を行います。 すでにインストールされている人や、いま電車の中なので手を動かすのは後にしようという人は、 この節は全部読み飛ばしてしまい、今回のメインである「デバッガーを使って "Hello World!" の裏側を覗く」へ一気に進んでもかまいません。

 まずはGo言語のインストールですが、各環境用のバイナリが用意されているので、下記のドキュメント経由で取得してください。

 インストールした後は、下記のドキュメントを参考に GOPATH を設定し、 GOPATH/bin にパスが通っていていることを確認してください。

 さらに、バージョン管理ツールであるgitが必要です。gitがシステムに入っていない場合はインストールしてください。 goではオープンソースのライブラリを外部のリポジトリから取得してきますが、その多くはgitで管理されているため、システムにgitがインストールされていないとあとで困ります。

 Go言語の文法については、まったくのプログラミング初心者でなければ、 Tour of Go日本語版 を一通りこなすだけで十分でしょう。 このチュートリアルだけでは難しいかもしれないポイントは、今回の記事の範囲では出てきませんが、次回以降は適宜補足していく予定です。

 さらに丁寧なGo言語についての説明がほしい方は、書店に並んでいるさまざまな書籍を参考にしてください。 丸善出版から今年(2016年)の6月に出版された、 プログラミング言語Go が今後の定番書になりそうです。

IntelliJでGoが使えるようにする

 統合開発環境は IntelliJ IDEA をおすすめします。 IntelliJは有償およびオープンソースで提供されていますが、Goの機能についてはサードパーティ製プラグインを利用し、 本体に最初からインストールされている機能の差が関係しないため、オープンソース版のCommunity Editionで大丈夫です。 なお、IntelliJの利用にはJava SEの開発キットも必要になります。システムに開発キットがインストールされていない場合は、オラクルのサイトからJDKを取得して設定してください。

 PyCharm、PHPStorm、RubyMine、WebStorm、Android Studioなど、IntelliJベースの他の開発環境でも問題ありません。 定義元へのジャンプができれば問題ないのでVimなどでも大丈夫ですが、以降はIntelliJを使って説明していきます。

 GoとIntelliJをそれぞれインストールしたら、IntelliJでGoが使えるように設定しましょう。 IntelliJを初めて起動すると、次のような画面が表示されます。

 この画面で右下にある[Configure]→[Plugins]を選択し、各種のプラグインを選択するダイアログを開いてください。 [Browse Repository]というボタンを選択するとサードパーティ製のプラグインの一覧が表示されます。 検索ダイアログに go と入力し、Go を選択すれば、Go言語対応のプラグインがインストールされます。 なお、プラグインを有効にするにはIntelliJの再起動が必要です。

 以上で、IntelliJでGo言語を使ったプロジェクトを扱えるようになりました。

はじめてのGoプロジェクト

 IntelliJで新規のプロジェクトを作成しましょう。 IntelliJを起動して表示される画面で[Create New Project]を選択してください (すでに起動済みで開発画面が表示されている場合には[File]メニューから新しいプロジェクトを作成できます)。

 新規プロジェクトの種類を選べるダイアログが開いたら、 Go を選択して、[Next]ボタンを押してください。

 次の画面ではGoのSDKを選択しますが、はじめてGoのプロジェクトを作成するときは、選択するものが画面に表示されていないかもしれません。 その場合にはダイアログの[Configure]を押して、インストールしたGo言語の場所を指定してください。 選択できる状態になっていたら、Go SDKを一つ選択して[Next]ボタンを押します。

 その次はプロジェクト名とプロジェクトの置き場のパスを設定する画面です。 ここでは helloworld と設定しました。

 これで新しいGoのプロジェクトが作成され、画面左上にはGoのマスコットキャラクタの絵が付いた helloworld というタブができているはずです。 このタブを右クリックして[New]→[Go File]を選択し、これからGoのコードを書く main.go ファイルを追加してください。

 次のコードを入力して、[Run]メニューから[Run]を選択すると、IntelliJの画面下のほうにターミナルが開いて Hello World! と表示されます。

package main
import "fmt"
func main() {
    fmt.Println("Hello World!")
}

デバッガーを使ってシステムコールを「見る」

 ここまでの手順で、Go言語でプログラムを書いて実行できるようになりました。 しかしこの連載の目的は、プログラムを書くだけでなく、その下のレイヤを覗いてみることです。 さきほど書いたGo言語の "Hello World!" プログラムの、さらに下のレイヤでは、いったい何が起きているのでしょうか。

 C/C++で、さらに下のレイヤのシステムプログラミングを学ぼうとしたら、ランタイムライブラリのソースコードを自分で取得してきて、 それをデバッグモードでビルドするなどの作業が必要になります。 OSの機能を直接利用するコードをC/C++で書こうという人は、そこまでやって勉強するしかないでしょう。

 一方、Go言語であれば、処理系に全環境用のソースコードもすべてバンドルされています。 そのため、デバッガーで処理を追いかけていくだけで、OSの機能を直接呼び出すコードまで簡単に見ることができます。 中身を勉強するにはとてもすぐれた環境といえます。

 WindowsのソースコードをmacOSで実行して何が起きているかを確認することさえ簡単にできます。 IntelliJでは、External LibrariesのGo SDKの部分からソースにアクセスできるので、そこで src/os/file_windows.go を開くだけです。

IntelliJでデバッガーを有効にする

 さっそく低レイヤを見るためにIntelliJでデバッガーを使えるようにしましょう。 IntelliJのGo言語プラグインにはソースコードのデバッガー機能が内蔵されています。 この機能を有効にすることで、一行ごとにステップ実行を行ったり、IDE上でブレークポイントを置いて処理がその行に到達したら一時中断させたりできます。

 IntelliJのGo言語プラグインでは、デバッガーのバッグエンドにdelveというツールを利用しています。 delveのインストールは、WindowsとLinuxの場合は下記のコマンドだけで完了します。

$ go get github.com/derekparker/delve/cmd/dlv

 macOSの場合は、同じコマンドを実行後、少し追加の作業が必要です。 具体的には、OSの機能を使って証明書を作り、それを登録する必要があります。 デバッガーは実行中のプログラムにアクセスしてすべての情報にアクセスできてしまうため、 悪意のあるユーザが下手に実行できないように、証明書の仕組みを使って認証するわけです。 下記コラムを参考にして証明書を作成、登録してください。

macOSでの証明書の作成と登録

 まず、キーチェーンアクセスという設定のためのアプリケーションを起動してください。 アプリケーションフォルダの中のユーティリティフォルダにあるはずです。 起動したら、[キーチェーンアクセス]→[証明書アシスタント]→[証明書を作成]を選択します。

 証明書作成ダイアログが開いたら、まず分かりやすい名前(この例では「dlv-cert」)を入力してください。 証明書のタイプはコード署名とし、「デフォルトを無効化」にチェックを入れます。

 次のダイアログでは有効期限を設定します。3650にすれば10年有効です。 以降のダイアログはデフォルトのまま[次へ]を何回か押してどんどん先に進み、最後の保存場所を設定する画面では[システム]を選択してください。 これで証明書ができました。 証明書ができたら再起動してください。

 作成した証明書を登録するには、ターミナルで次のコマンドを実行します。

$ cd $GOPATH/src/github.com/derekparker/delve ⏎
$ CERT=<証明書の名前> make install

 以上で、macOSでデバッガーが使えるようになります。


デバッガーを使って "Hello World!" の裏側を覗く

 さっそくデバッガーを使って、"Hello World!" プログラムのテキスト出力の裏で何が行われているのか覗いてみましょう。

 デバッガーは、その名称だけを見ると、まるで「バグを修正するツール」であるかのように思えます。 しかしデバッガーにバグを修正する機能はありません。 デバッガーに備わっているのは、プログラムが実行される様子をプログラマーが観察する機能だけです。 プログラムの処理を1つずつ追いかけていくデバッガーの機能(「ステップ実行」と言います)を使い、先ほどの短いソースコードが実際にOSの機能を使っている箇所へと潜っていきましょう。

 まずはIntelliJの画面右側に表示されているソースコードで fmt.Println の行の左側をクリックし、ブレークポイントを設置してください。

 この状態で、IntelliJでデバッグを開始します。 デバッグを開始する方法はいくつかありますが、[Run]メニューから[Debug]を選ぶと下記のようなダイアログが表示されるので、 [Build main.go and run]→[Debug]を選んでください。

 するとプログラムの実行が始まりますが、 プログラムの初期化処理が終わってブレークポイントとして選択した行に処理がくると、そこでプログラムの実行はいったん中断します。

 ここから、デバッガーのステップ実行を使って、徐々に下のレイヤへと降りていくことにします。 その際に使うデバッガーの機能は次の3種類です。

  • ステップイン(Step Into): 関数呼び出しの中に飛び込みます。下のレイヤーに降りていくときに使います。
  • ステップオーバー(Step Over): 見えているソースコードの次の行に移動します。カーソル位置の関数の中は実行されて終了するところまで処理を進めます。
  • ステップアウト(Step Out): 今実行している関数が終了するところまで処理を進めます。

 IntelliJの下部に表示される[Console]タブの横にボタンがいくつか並んでいると思いますが、これらのボタンを押すことで上記の操作が可能です。 各ボタンの役割はマウスオーバーすると表示されます。

ステップインしすぎたり、間違ってステップオーバーしてしまった場合には

 ステップオーバーすべき箇所で間違ってステップインしてしまい、身に覚えのない関数の中に入ってしまったら、何度かステップオーバーしてその関数をやり過ごしてください。

 逆に、ステップインすべき場所でステップオーバーしてしまうと、前に戻ることはできません。 [Return 'Build main.go' and run]というボタンを押して最初からやり直してください。

 ではさっそく下のレイヤーに降りていきましょう。下のレイヤを探るときに主に使うのはステップインです。 さきほど fmt.Println にブレークポイントを設定してデバッグを開始したので、いまはここでプログラムの実行が中断しています。 ここから一回だけステップインすると、 convT2E() という関数の中に入ります。 これはGo言語が関数呼び出しの引数の変換処理のために挿入する関数呼び出しです。 この関数はGo言語のソースでは runtime ディレクトリ以下にある iface.go というファイル内で定義されているので、 IntelliJの右側の画面には iface.go ファイルが表示されているはずです。

 convT2E() 関数は、いまは特に重要ではないため中には飛び込まず、単純に処理を進めることにします。 ステップインしてしまうと関数呼び出しの中に入っていってしまうので、今度はステップオーバーをしてください。 ステップオーバーを何回か実行すると、 main.go に戻ってきます。そこまで実行を進めたら、そこでステップインしましょう。

 convT2E が気になる人のために補足しておきます。これは、さまざまな値を interface{} 型へ変換する関数です。 interface{} はGo言語におけるヴァリアント型と呼ばれるもので、どんな型でも受け付けるという動的言語の変数のような型です。 fmt.Println の引数は、さまざまな型を受け取るために、interface{} の可変長引数になっています。 main.go のプログラムは文字列を渡しているので、 fmt.Println に処理が渡る前に convT2E による変換処理が挟まっています。

 main.go に戻ってきたところでステップインすると、ここでようやく fmt.Println 関数の中に入ります。 画面では fmt/print.go ファイル内に下記のようなコードが見えていると思います。

func Println(a ...interface{}) (n int, err error) {
    return Fprintln(sys.Stdout, a...)
}

 これはどうやら Fprintln を呼び出すだけの関数のようです。 Fprintln の最初の引数には sys.Stdout を渡しています。 もっとステップインして Fprintln まで進んでみましょう。 Fprintln 関数は、下記のような具合に定義されているはずです。

func Fprintln(w io.Writer, a ...interface{}) (n int, err error) {
    p := newPrinter()
    p.doPrintln(a)
    n, err = w.Write(p.buf)
    p.free()
    return
}

 Fprintln の定義の最初の2行では、文字列をフォーマット文字列に従って整形する printernewPrinter で作成し、それでまずは文字列を生成しています。 それを3行めで w.Write に渡して書き込んでいるようです。 w は、その前の関数で渡された sys.Stdout です。

 次は、この Write にステップインしましょう。 そのためには、Fprintln の定義内を w.Write が出てくる3行めまでステップオーバーし、そこでステップインします。 os パッケージで次のように定義された File#Write() 関数に飛ぶはずです。

func (f *File) Write(b []byte) (n int, err error) {
    if f == nil {
        return 0, ErrInvalid
    }
    n, e := f.write(b)
    :
}

 File#Write() 関数では、write という非公開メソッドを呼んでいることがわかります(上記の定義だと4行め)。 Go言語では、名前の先頭が大文字だと公開メソッド、小文字だと非公開メソッドなので、 write は非公開メソッドですね。 この write() メソッドまでステップオーバーし、その中へステップインしてみましょう。

 ここから先は、環境によって固有のコードに飛びます。 Unix系OS(macOSやLinuxなど)では、write() メソッドは次のようになっていると思います。 for ループに囲まれて、送信が終わるまで何度も syscall.Write を呼んでいることがわかります。 この syscall.Write がシステムコールです。

func (f *File) write(b []byte) (n int, err error) {
    for {
        bcap := b
        if needsMaxRW && len(bcap) > maxRW {
            bcap = bcap[:maxRW]
        }
        m, err := fixCount(syscall.Write(f.fd, bcap))
        n += m
        :
    }
}

 Windowsで実行している場合は、Unix系OSとは別のファイルがIntelliJの画面に表示されるはずです。

func (f *File) write(b []byte) (n int, err error) {
    f.l.Lock()
    defer f.l.Unlock()
    if f.isConsole {
        return f.writeConsole(b)
    }
    return fixCount(syscall.Write(f.fd, b))
}

 処理は違いますが、やはりこちらもシステムコール syscall.Write を呼んでいますね!

 最後に、今回のおさらいとしてコールグラフ(関数の呼び出し関係のグラフ)を紹介します。

今回のまとめと次回予告

 連載の第1回目では、Go言語の "Hello World!" プログラムの実行時に下のレイヤでどんなシステムコールが呼び出されているのか、 デバッガーを使ってレイヤを降りていくことで実際に確かめてみました。 Go言語の基本的な機能だけで、下のレイヤで起こることを意外なほどあっさりと確かめられたと思います。

 次回はシステムコールよりも少しだけ高いレイヤーにもどり、 io.Writer を例にGo言語のインタフェースによる抽象化の仕組みについて説明します。 io.Writer を知ることで、ファイルやインターネット通信のためのソケットなど、OSから提供されている機能の活用がしやすくなります。

注釈

  1. パフォーマンス・チューニングを行う場合には「まずは計測せよ」というのが大原則ですが、遅そうなポイントの推測がつくほうが、効率よく計測すべきポイントを見つけられます。
  2. https://golang.org/doc/faq#What_is_the_purpose_of_the_project
  3. https://www.quora.com/Which-language-has-the-brightest-future-in-replacement-of-C-between-D-Go-and-Rust-And-Why/answer/Andrei-Alexandrescu?srid=T7AW

筆者紹介――渋川よしき

 C++/Python/Goを趣味で書くIT系企業のプログラマー(横浜ベイスターズCS進出おめでとうございます)。3児の父。Sphinx-Users.jpのファウンダー。著書に『Mithril』(オライリー・ジャパン)、翻訳に『エキスパートPythonプログラミング』(アスキーMW)、『アート・オブ・コミュニティ』(オライリー・ジャパン)など。

編集者紹介――鹿野桂一郎

 ラムダノート株式会社 代表取締役、TechBooster CEO(Chief Editing Officer)。HaskellとSchemeとLaTeXでコンピュータとかネットワークとか数学の本を作るのをお手伝いする仕事。

この記事をシェアしよう

週刊アスキーの最新情報を購読しよう

本記事はアフィリエイトプログラムによる収益を得ている場合があります

この連載の記事