• HOME
  • 記事一覧
  • なぜ print(‘hello’) は表示されるのか ── 1行のPythonが動くまで
  • 趣味
  • その他

なぜ print(‘hello’) は表示されるのか ── 1行のPythonが動くまで

O.Yu
O.Yu
なぜ print(‘hello’) は表示されるのか ── 1行のPythonが動くまで

はじめに

#!/usr/bin/env python3
print('hello, world')

このPythonスクリプトを hello.py として保存し,ターミナルで実行すると,たいていのLinux環境では何事もなく hello, world の文字列が表示されます。

一見当たり前に思えるこの挙動の裏では,シェル・カーネル・インタプリタが連携して,多くのシステムコールが呼び出されています。
本記事では,その舞台裏をレイヤごとに覗き込みます。

取り上げるレイヤとスコープ

本稿に登場するレイヤは次のような図をイメージしています。
それぞれの説明は本文中に記載します。

どのていど説明するの?
本稿では「実際にカーネルが呼ばれる地点」にフォーカスをあてて構成します。
システムコールやlibc層の関数については名称は出しますが詳細なAPIの説明は割愛します。

ゴール

  • hello, world がどの層でどう処理されるかの理解を深める
  • シェルが悪いのか,Pythonが悪いのか,それともカーネルか。を切り分けるヒントを学ぶ

1. シェルがやっていること

1-1. コマンド解析と実行可能判定

  1. 構文解釈
    bash は入力された文字列 ./hello.py をトークンに分割し,最初の部分が相対パスであることを判断します
    ここではカレントディレクトリに存在する hello.py を指しています
  2. ファイル探索と権限チェック
    • ファイルが存在しているか,実行ビット x が付与されているかを確認
    • 実行ビットが無い場合 → "Permission denied" を即時出力して実行を中断
  3. 実行準備
    bash は自プロセスを残したまま子プロセスを生成

hint
実行に失敗する場合は権限不足が原因となるケースがあります。トラブル時はまず ls -l で実行ビット x が付与されているかを確認してみてください。

1-2. プロセス生成

  1. 子プロセスを生成
    bash はまず自プロセスで access() などを使って実行権限を確認したあと, fork() (内部では clone3 または clone が呼ばれる) で子プロセスを作ります
  2. 子プロセス側の処理
    • 子プロセスで execve("./hello.py", …) を発行し,自身を hello.py で置き換えます。
    • 親プロセスである bash 本体は waitpid() で,子プロセスの終了を待機

2. shebangとインタプリタの解決

2-1. shebangは誰が読む?

  1. execve() 直後のカーネル処理
    カーネルはファイルの先頭を検査し, #! から始まっていれば,スクリプト扱いと判断されます
  2. shebang文字列を解釈
    以降の文字列を実行すべきプログラムと引数としてパースします
  3. メモリ再ロード
    元の hello.py プロセスは破棄され,shebangで指定されたプログラムに置き換わります

2-2. /usr/bin/env python3 の意味とPATH解決

  1. shebangのコマンド部分は /usr/bin/env
  2. env がPATHを走査し python3 を探索
  3. 見つかった python3hello.py を渡して execve() を実行
    • strace を実行すると複数回にわたって execve() が呼び出されていることが確認できます

hint
複数バージョンのPythonが混在している環境では,どの python3 が呼ばれたかを which -a python3 で確認すると依存関係トラブル解決の助けになります。

3. Pythonがスクリプトをどう処理するか

3-1. ソース読み込み → 抽象構文木(AST)

  • hello.py を読み取り字句解析でトークン化
  • パーサがASTを生成
$ python3 -m ast hello.py
Module(
   body=[
      Expr(
         value=Call(
            func=Name(id='print', ctx=Load()),
            args=[
               Constant(value='hello, world')]))])

このように実行すると,スクリプトのASTを確認することができます。

3-2. AST → バイトコード

コンパイラが各ノードをPythonバイトコードに変換します。
バイトコードは,後述する「Python仮想マシン」がスクリプトを実行するための中間表現です。
実際にどんな命令が生成されているかは,標準ライブラリの dis モジュールで確認できます。

import dis
dis.dis("print('hello, world')")
  0           RESUME                   0

  1           LOAD_NAME                0 (print)
              PUSH_NULL
              LOAD_CONST               0 ('hello, world')
              CALL                     1
              RETURN_VALUE

3-3. バイトコード評価

生成されたバイトコードは,Pythonインタプリタの評価ループによって命令単位で実行されます。
このループの実装は,次のような処理をしています:

  • スタックに値を積む/取り出す
  • 関数を呼び出す
  • 例外処理を挟む

ここで先程の命令が処理され,最終的に print() 関数が呼ばれます。

note
Python v3.11 以降はSpecializing Adaptive Interpreter (適応的特殊化インタプリタ) が導入され,評価プロセスの最適化が進みました。

4. I/O ルート

4-1. print() の内部

print() はPythonの組み込み関数で,内部的には sys.stdout.write() を呼ぶように実装されています。
sys.stdout オブジェクトは,次のような多層にラップされた出力ストリームオブジェクトです。

# デフォルト設定の例

TextIOWrapper
 |
 +-- BufferedWriter
      |
      +-- FileIO (C拡張,最下層)

各層の役割:

  • TextIOWrapper : 文字列をバイト列にエンコード
  • BufferedWriter : バッファリング処理
  • FileIO : 最終的にOSのファイル記述子に対して書き込み

4‑2. write() によるファイルディスクリプタへの書き出し

  • 内部 C 実装で直接 write(fd=1, buf, len) を呼び出す
    fd = ファイルディスクリプタ。すぐ後ろのセクションに補足を記載しています
  • カーネルは fd=1 の対象TTYへバッファをコピー
  • 仮想端末ドライバが画面バッファに反映し,文字が出力されます

4-3. (補足) ファイルディスクリプタと標準出力

UNIX系のOSでは,すべての入出力はファイルディスクリプタ(fd)を介して行われます。
通常のプロセスは起動時に以下の3つfdを持っています

ファイルディスクリプタ意味
0標準入力 (stdin)
1標準出力 (stdout)
2標準エラー出力 (stderr)

つまり,Pythonから最終的に fd = 1"hello, world\n" が送られることで,ターミナルに文字列が表示されるわけです。

5. カーネルI/Oでの write() 処理(標準出力の場合)

5-1. Virtual File System (VFS)

sys_write() システムコールは,最初にVFSという抽象レイヤに入ります。
このVFSは,異なる種類のファイルやデバイスに対して統一的なインターフェースを提供する役割を持っています。

呼び出し手順は次のようになります:

  1. ファイルディスクリプタ(fd) に基づいて, struct file を取得します。
  2. その struct file が指すファイル操作の関数ポインタを介して,適切なデバイスドライバ(この場合はTTYドライバ)を呼び出します。
    TTY: UNIX系のOSが"端末"として扱う入出力デバイスの総称
  3. TTYドライバの tty_write() を実行し,データを画面に書き込みます。

5‑2. 端末(TTY) へのルート

write() が呼ばれてから画面に hello, world が出力されるまで,データは次のように流れていきます。

ユーザ空間
   │ write(fd=1, ...)
   v
カーネル空間
   sys_write() → VFS → tty_write() → バッファ → 画面

実際に画面出力されるまでの過程では,割り込みやスケジューラを介して非同期に処理されます。
これらの処理はスタックに積まれ,すべて解決するとユーザプロンプトが返ってきます。

6. 全レイヤの俯瞰と要点まとめ

6-1. print('hello, world') という1行の裏起こること

これまで説明したものを短く求めると次のようになります:

処理の主体処理の流れ
ユーザ入力./hello.py
シェルaccess()clone() → 子プロセスに分離 → execve(hello.py)
カーネルshebang解析 → execve(python3)
Python VMソース → AST → バイトコード → print()
I/OスタックTextIOBufferedFileIOwrite()
カーネルI/OVFS → tty_write() → (割り込み処理(IRQ))
端末画面に "hello, world" が表示される

6‑2. レイヤ別 "ここを見る" チェックリスト

今回ご紹介した視点で処理や障害を追跡するツールをまとめました:

レイヤツール障害切り分けのポイント
シェルls -l, which実行ビットとPATH
カーネルstrace, dmesgexecveの呼び出し, I/O ブロック
Pythonpython3 -m dis, python3 -uバッファ遅延・バイトコード確認
I/Ostty -a, /dev/ptstty ごとの設定違い

ご自身でも確認したい。デバッガで解決しない問題に取り組みたい。といったときの参考にしてみてください。

7. おわりに

print('hello, world') と書いて実行する。
それだけのことに,これほど多くの仕組みや技術が関わっていたということに,少し驚きを感じた方もいるかもしれません。

こうした仕組みは,普段「意識しなくても動く」ものです。しかし,少しだけ手を伸ばして中を覗いてみると,意外な面白さや発見,そして「わかるって楽しい」という感覚が待っています。

本稿では省略している箇所もたくさんあります。
是非,これを読んで下さってるあなた自身も print('hello, world') の裏側を覗いてみてください。

新着記事一覧へ