Pythonの print はどう動く? ― バイトコードからlibcの呼び出しまで
はじめに
Pythonを学ぶとき、最初に書くコードといえば、やはりこれでしょうね!
print("Hello, world!")
実行すれば一瞬で完了しますが、裏側ではPython仮想マシン、C言語の標準ライブラリ(libc)、そしてOSカーネルがバケツリレーのようにデータを渡しています。
Pythonの標準機能の多くは、裏側でC言語の関数を呼び出すことで実現されています。
今回は「Python編」として、Pythonのソースコードが実行され、最終的に「C言語の世界(libc)」 に足を踏み入れる瞬間までを追ってみましょう。
検証環境
本記事では、以下の環境で調査を行いました。
- アーキテクチャ: x86_64
- OS: Ubuntu 22.04.5 LTS
Pythonの実装はCPythonを使用しました。
CPythonのソースコードはGitHubの公式リポジトリの「v3.14.0」タグをチェックアウトして使用しました。
git clone --depth 1 https://github.com/python/cpython.git
cd cpython
git checkout v3.14.0
また、Pythonプログラムを実行する際は、uvを用いてバージョンを固定しています。
uv init --app dis-hello
cd dis-hello
uv python install 3.14.0
uv python pin 3.14.0
# main.pyを編集
uv run main.py
Pythonインタプリタという名のプログラム
深掘りを始める前に、私たちが普段pythonやpython3コマンドとして呼び出しているものの正体を整理しておきましょう。
よく「Pythonプログラムを実行する」と言いますが、厳密にはPythonインタプリタというプログラムが、Pythonソースコードをデータとして読み込んで処理している状態です。
インタプリタもまた、コンパイルされたプログラムである
Pythonインタプリタ自体(/usr/bin/python3などにある実行ファイル)は、誰かが魔法で作ったわけではありません。
- インタプリタ自体のビルド
CPythonの開発者が書いた大量のC言語コードを、gccなどのコンパイラでコンパイルして作られた、実行可能バイナリです。私たちが普段インストールしているのはこの完成品です。 - スクリプトの実行
私たちがpython3 hello.pyを実行するとき、OSから見れば、単に「pythonという名前のC製アプリケーションが起動し、テキストファイルを読み込んだ」に過ぎません。
インタプリタの内部動作
インタプリタはソースコードを1行ずつ実行するわけではなく、以下のような段階を踏んで処理を行います。
- コンパイル
Pythonソースコードをバイトコードに変換します。[1]バイトコードは「Pythonのためのミニ命令セット」で、あり、Python仮想マシンが解釈して実行します。実際のCPU命令とは異なり、Python独自の命令セットです。 - 実行
バイトコードをPython仮想マシンが解釈し、実行します。
つまり、print関数が実行される前に、ソースコードは一度「仮想マシンへの命令書」に変換されているのです。
バイトコード
前節で、Pythonは実行前にバイトコードを作ると説明しました。
では、実際にprint("Hello, world!")がどのようなバイトコードに変換されるのかを、標準ライブラリのdis (disassembler) モジュールを使って確認してみましょう。
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
この出力が、Python 仮想マシンが実際に処理している命令の列です。一つずつ読み解いていきましょう。
-
LOAD_NAME:printという名前のオブジェクトをロードします。ここでは組み込み関数のprintがロードされます。[2] -
PUSH_NULL: 関数呼び出しの調整用です。詳細は省きます。 -
LOAD_CONST:"Hello, world!"という文字列定数をロードします。 -
CALL: 先ほどロードしたprint関数を、引数("Hello, world!")とともに呼び出します。
CALL 命令の先
CALLが実行された瞬間、制御はPython仮想マシンから離れ、C言語で実装されたprint関数の実体へと移ります。[3]ここが、PythonとC言語の世界の境界線です。
C言語の世界へ
print関数の実装のみを紹介してもいいのですが、せっかくなのでCPythonインタプリタのソースコードを辿りながら、どのようにしてC言語の関数が呼び出されるのかを見ていきましょう。
大まかな流れとしては、先にprint関数などの組み込み関数が定義され、そのあとにユーザが書いたPythonコードの実行が始まります。
Pythonのはじまり
C言語では、基本的にプログラムの実行はmain関数から始まります。
PythonもただのCプログラムなので、この関数から実行が始まります。
#include "Python.h"
int
main(int argc, char **argv)
{
return Py_BytesMain(argc, argv);
}
そのPy_BytesMainの実装も確認できます。
int
Py_BytesMain(int argc, char **argv)
{
_PyArgv args = {
.argc = argc,
.use_bytes_argv = 1,
.bytes_argv = argv,
.wchar_argv = NULL};
return pymain_main(&args);
}
このようにして、ここからの関数呼び出しを辿っていきます。
さすがに全てをここで解説するのは大変なので、重要な部分だけを抜粋していきます。
本記事で示すコードスニペットは、実際のソースコードから一部を抜粋・編集したものです。詳細は公式リポジトリを参照してください。
組み込みモジュールの登録
まず、組み込みモジュール(print関数を含むbuiltinsモジュール)が初期化され、インタプリタに登録されます。
static PyStatus
pycore_init_builtins(PyThreadState *tstate)
{
// ...
// builtinsモジュール
PyObject *bimod = _PyBuiltin_Init(interp);
// ...
// builtinsモジュールの辞書オブジェクトを取得
PyObject *builtins_dict = PyModule_GetDict(bimod);
// ...
// インタプリタの組み込み名前空間に登録
interp->builtins = Py_NewRef(builtins_dict);
// ...
}
ここで呼び出されている _PyBuiltin_Init は、Python/bltinmodule.c に定義されています。
組み込み関数の登録
この関数で、モジュールを作成している箇所があります。
PyObject *
_PyBuiltin_Init(PyInterpreterState *interp)
{
// ...
mod = _PyModule_CreateInitialized(&builtinsmodule, PYTHON_API_VERSION);
// ...
}
元となっているbuiltinsmoduleを見てみましょう。
ここに、printやlenといった組み込み関数のエントリが並んでいます。
static struct PyModuleDef builtinsmodule = {
builtin_methods,
};
static PyMethodDef builtin_methods[] = {
// input関数
BUILTIN_INPUT_METHODDEF
// len関数
BUILTIN_LEN_METHODDEF
// max、min関数
{"max", _PyCFunction_CAST(builtin_max), METH_FASTCALL | METH_KEYWORDS, max_doc},
{"min", _PyCFunction_CAST(builtin_min), METH_FASTCALL | METH_KEYWORDS, min_doc},
// print 関数が登録されている!
BUILTIN_PRINT_METHODDEF
};
さらにBUILTIN_PRINT_METHODDEFの定義を辿っていきます。
#define BUILTIN_PRINT_METHODDEF \
{"print", _PyCFunction_CAST(builtin_print), METH_FASTCALL|METH_KEYWORDS, builtin_print__doc__},
これはC言語の「マクロ」という機能で、BUILTIN_PRINT_METHODDEFの部分を、後に続くコードに置き換えます。
いろいろと書いてありますが、重要なのはbuiltin_printという関数で、これがprint関数の本体です。
builtin_print関数
ようやくprint関数のC言語実装にたどり着きました。
重要な部分を抜粋して紹介します。
この関数には、モジュールオブジェクトと、Python 側から渡された引数(位置引数・キーワード引数)がまとめて渡されます。
static PyObject *
builtin_print(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObject *kwnames)
{
print関数のキーワード引数はここで処理されているんですね。
print関数では、fileで指定されたファイルに対して出力を行ったり、endで末尾に付ける文字列を指定したりできます。
Pythonだと、引数を指定しないとデフォルト値が使われるように振る舞いますが、C言語側では、Noneにしてから、デフォルト値を設定しています。
static const char * const _keywords[] = {"sep", "end", "file", "flush", NULL};
引数はデフォルト値ではなく、Noneで初期化されます。
PyObject *sep = Py_None;
PyObject *end = Py_None;
PyObject *file = Py_None;
int flush = 0;
これは、このラッパー関数が自動生成されているためです。
CPythonの実装では、組み込み関数の引数処理にArgument Clinicというツールが使われています。引数のパースはこの関数で、実際のロジックはbuiltin_print_implで行われます。
引数解析の時点では、指定されていない引数をNoneにしておき、実際の実装でデフォルト値を設定する、という形です。
return_value = builtin_print_impl(module, __clinic_args, args_length, sep, end, file, flush);
このbuiltin_print_impl関数が、実際に出力を行う部分です。
static PyObject *
builtin_print_impl(PyObject *module, PyObject * const *args,
Py_ssize_t args_length, PyObject *sep, PyObject *end,
PyObject *file, int flush)
{
Hello, world!ではfile引数を指定しないため、デフォルトで標準出力(sys.stdout)に出力されます。
if (file == Py_None) {
file = _PySys_GetRequiredAttr(&_Py_ID(stdout));
}
そして、forループで引数を一つずつ文字列に変換して出力します。
for (i = 0; i < args_length; i++) {
if (i > 0) {
if (sep == NULL) {
err = PyFile_WriteString(" ", file);
}
else {
err = PyFile_WriteObject(sep, file, Py_PRINT_RAW);
}
}
// Py_PRINT_RAW は「引用符などを付けずにそのまま出力せよ」というフラグ
// ここで引数を出力している
err = PyFile_WriteObject(args[i], file, Py_PRINT_RAW);
}
文字列の最後に任意の文字列を追加するend引数は、値が指定されていない場合は改行を出力します。
if (end == NULL) {
err = PyFile_WriteString("\n", file);
}
PyFile_WriteObjectはファイルオブジェクトのwriteメソッドを呼び出します。
int
PyFile_WriteObject(PyObject *v, PyObject *f, int flags)
{
PyObject *writer, *value, *result;
writer = PyObject_GetAttr(f, &_Py_ID(write));
result = PyObject_CallOneArg(writer, value);
こういうのは「ダックタイピング」と呼ばれるみたいです。クラス名・型名ではなく、そのオブジェクトが持っているメソッドや振る舞いに注目して、「この処理で使えるかどうか」を判断する考え方です。以下の記事が詳しいです(英語)。
すなわち、ここではstdoutオブジェクトのwriteメソッドが呼び出されると思われます。
それでは、このオブジェクトの実装を探っていくことにしましょう。
sys.stdoutオブジェクト
init_sys_streams関数では、標準入出力が初期化されます。
static PyStatus
init_sys_streams(PyThreadState *tstate)
{
// stdoutのファイルディスクリプタを取得
fd = fileno(stdout);
// TextIOWrapper
std = create_stdio(config, iomod, fd, 1, "<stdout>",
config->stdio_encoding,
config->stdio_errors);
// ↓登録している!
// ユーザーがstdoutを上書きしたときのバックアップ
PySys_SetObject("__stdout__", std);
// sys.stdout
_PySys_SetAttr(&_Py_ID(stdout), std);
ファイルディスクリプタからTextIOWrapperオブジェクトが作成され、sys.stdoutに登録されているようです。
ファイルディスクリプタは、UNIX系OSがファイル・ディレクトリ・ソケット・端末・デバイスなどの「ファイルっぽいもの」を一元的に扱うための仕組みです。
標準出力はファイルディスクリプタの番号1に対応しています。0は標準入力、2は標準エラー出力です。
TextIOWrapperオブジェクトは、テキスト入出力を扱うためのラッパークラスです。このオブジェクトが、実際に文字列を書き込むためのwriteメソッドを持っています。その実装を見てみましょう。
builtin_printの時と同様に、引数処理と実装が分かれています。
static PyObject *
_io_TextIOWrapper_write(PyObject *self, PyObject *arg)
{
// 引数処理
return_value = _io_TextIOWrapper_write_impl((textio *)self, text);
static PyObject *
_io_TextIOWrapper_write_impl(textio *self, PyObject *text)
{
この関数は実装が大きいため、要点を抜粋して紹介します。
まず、この関数は文字列をバイト列に変換し、pending_bytesというバッファに溜め込みます。そして、バッファがいっぱいになったり、フラッシュが要求されたりしたときに、実際の書き込み処理を行います。
フラッシュとは、バッファに溜め込んだデータを出力先に書き出す操作のことです。
実はTextIOWrapperは、さらに下位にBufferedWriterというバッファリング用のオブジェクトを持っており、最終的にはそこにデータを書き込み、OSに渡されます。
したがって、バッファは2つあり、フラッシュも2種類あります。
-
TextIOWrapperのpending_bytesバッファを下位層のBufferedWriterに書き込むフラッシュ -
pending_bytesをBufferedWriterに書き込み、さらにBufferedWriterをフラッシュしてOSにデータを書き込むフラッシュ
まず、巨大なデータなら先にフラッシュします。こちらは下位層に書き込むフラッシュです。
if (bytes_len >= self->chunk_size) {
while (self->pending_bytes != NULL) {
if (_textiowrapper_writeflush(self) < 0) {
// ...
}
}
}
このpending_bytesは、リストになっています。細かい書き込みを複数回行うとパフォーマンスが悪化するため、ある程度まとめてから出力するようになっています。
そして、バッファにデータを追加します。
if (self->pending_bytes == NULL) {
self->pending_bytes = b;
}
else if (!PyList_CheckExact(self->pending_bytes)) {
PyObject *list = PyList_New(2);
self->pending_bytes = list;
データが一つならそのままセット、複数ならリストにまとめる、という実装ですね。
そして、pending_bytesのサイズを更新します。
self->pending_bytes_count += bytes_len;
最後に、必要に応じてフラッシュを行います。フラッシュを行うのは以下の場合です。
- 溜め込んだ量が
chunk_sizeを超えたとき - 行バッファリングが有効で、
"\n"が来たとき - 即時書き込みの設定がされているとき
ここでも、下位層のバッファself->bufferに書き込まれます。
if (self->pending_bytes_count >= self->chunk_size || needflush ||
text_needflush) {
if (_textiowrapper_writeflush(self) < 0)
return NULL;
}
その直後にあるこの処理は、自分も、下位層もフラッシュして、OSへデータを書き込む部分です。
行ごとに出力を行う「行バッファリング」が有効な状態で改行が来たときは、即座に書き込むようになっています。
人間にとっては行ごとに表示するのが自然なため、ターミナルの標準出力は通常このモードになっています。
ファイルに書き込む場合は、通常のバッファリングモードになります。
if (needflush) {
if (_PyFile_Flush(self->buffer) < 0) {
return NULL;
}
}
通常、Hello, world!はコマンドラインで実行されるため、行バッファリングモードで動作します。したがって、改行が来た時点でOSにデータが書き込まれます。_PyFile_Flush関数は、下位層のBufferedWriterオブジェクトのflushメソッドを呼び出します。
BufferedWriterオブジェクト
定義にジャンプを繰り返していくと、フラッシュの処理にたどり着きます。
static PyObject *
_io__Buffered_flush_impl(buffered *self)
{
PyObject *res;
res = buffered_flush_and_rewind_unlocked(self);
}
static PyObject *
buffered_flush_and_rewind_unlocked(buffered *self)
{
PyObject *res;
res = _bufferedwriter_flush_unlocked(self);
↓ここですね。
static PyObject *
_bufferedwriter_flush_unlocked(buffered *self)
{
while (self->write_pos < self->write_end) {
// self.rawのwriteが呼ばれる
// raw(self.raw)とは、FileIO のこと
n = _bufferedwriter_raw_write(self,
self->buffer + self->write_pos,
Py_SAFE_DOWNCAST(self->write_end - self->write_pos,
Py_off_t, Py_ssize_t));
self->write_pos += n;
self->raw_pos = self->write_pos;
}
}
バッファの中身をひたすら書き出しています。_bufferedwriter_raw_writeの中でself->rawのwriteメソッドが呼び出され、最終的にOSにデータが渡されます。
ここでは、rawというのはFileIOオブジェクトのことを指します。
名前からして、標準出力のファイルディスクリプタに直接書き込む役割を持っていそうですね。
FileIO.writeメソッド
こちらが実装です。
static PyObject *
_io_FileIO_write_impl(fileio *self, PyTypeObject *cls, Py_buffer *b)
{
Py_ssize_t n;
n = _Py_write(self->fd, b->buf, b->len);
_Py_writeという関数は以下の_write_implを呼び出しています。
この関数こそが、C言語のランタイム(libc)のwrite関数を呼び出し、OSにデータを書き込む部分です。
static Py_ssize_t
_Py_write_impl(int fd, const void *buf, size_t count, int gil_held)
{
書き込みでは、GIL(Global Interpreter Lock)が保持されているかどうかを確認しています。
これは同時に複数のスレッドがPythonオブジェクトにアクセスするのを防ぐための仕組みです。Python バイトコードを実行するときは1スレッドだけになるようロックすることで、スレッドセーフを実現しています。
ファイル書き込みはI/O操作であり、CPUにとっては非常に長い待ち時間が発生する可能性があります。したがって、書き込み中はGILを解放し、他のスレッドがPythonオブジェクトにアクセスできるようにしています。
例えば、大量のログを出力している最中でも、Webサーバーのリクエスト処理など、他のスレッドが止まらずに動けるようになります。
if (gil_held) {
do {
Py_BEGIN_ALLOW_THREADS // 解放!
// libcのwrite関数を呼び出している!
n = write(fd, buf, count);
Py_END_ALLOW_THREADS // スレッドを再取得
} while (n < 0 && err == EINTR &&
!(async_err = PyErr_CheckSignals()));
}
else {
// シグナルが来ると中断されることがある
// その場合は再度行う
// ただし、Ctrl + C(SIGINT)はPythonでハンドリングされエラーになる
do {
n = write(fd, buf, count);
} while (n < 0 && err == EINTR);
}
return n;
}
ありましたね。n = write(fd, buf, count);と書かれています。このwriteは、C言語の標準ライブラリで定義されているものです。通常は GNU Cライブラリ(glibc)に含まれている実装が使われます。これは動的リンクされているため、CPython自体のバイナリには含まれていません。実行環境にインストールされているlibc(たとえば、GNU Cライブラリ/glibc)の実装が使われます。
というわけで、ついにPythonの世界からC言語の標準ライブラリに到達しました。あとはlibcがOSカーネルにシステムコールを発行して、OSが処理を行います。
おわりに
一行のシンプルなプログラムでも、裏側では多くの層が連携して動作していることが分かりましたね。
こういう実装の深掘りは、コードを読む練習にもなり、言語のランタイムやOSの仕組みを理解する助けにもなります。
GILやバッファリング、システムコールといった概念を、副産物として一緒に学べたのも大きな収穫です。
こういう感じで実装を覗くのは、学べることが多いので今後もやっていきたいですね。
本記事は「KDIX CS Advent Calendar 2025」に参加しています。
シリーズの紹介
本記事は「Hello World のひみつ」シリーズの Python 編です。
すでに公開している Rust 編では、println! から libc::write までの流れを追いかけています。
Discussion