pyspa Advent Calendar 2021のエントリーです。昨日は@kuenishiさんでした。
どんなプログラミング言語でも、最終的にはOSへのシステムコールとなってプログラマーがやりたいことを実現しています。文字列をコンソールに出したり、ファイル入出力、ネットワーク入出力などなど。Goの場合Goならわかるシステムプログラミングという本がありますね。もちろん、みんな一人10冊ずつぐらいお買い上げのことだと思いますが、せっかくなので他の言語でも見てみましょう。
ここで扱うのは先日、TIOBEインデックスでプログラミング言語の人気No.1になったPythonです。
ただし、Pythonでメインの処理系のCPythonではなくてPyPyで見ていきます。理由は後から説明します。
PyPyのコードは次の場所でホストされています。
早速、次のコードの中身を見ていきましょう。
print("hello world")
print()関数
Pythonは何もインポートしなくても使える関数は __builtins__
の中にいます。PyPyリポジトリの/pypy/module/__builtin__/
というフォルダがそのコードに該当しそうです。この中のapp_io.pyにそれっぽいものがありました。
Pythonのprint()
はfileオプションで出力先を変更できます。何もしていないければ標準出力をとってきています。内部で定義されたwrite()
関数はこれのwrite()
メソッドを呼んでいます。最後に呼ばれるのはこのwrite()
メソッドですね。
def print_(*args, **kwargs):
"""The new-style print function from py3k."""
fp = kwargs.pop("file", None)
if fp is None:
fp = sys.stdout
if fp is None:
return
def write(data):
if not isinstance(data, basestring):
data = str(data)
fp.write(data)
# 途中省略
for i, arg in enumerate(args):
if i:
write(sep)
write(arg)
write(end)
次にsys.stdout
を探索します。この中に定義されていました。
class Module(MixedModule):
# 中略
interpleveldefs = {
'__name__' : '(space.newtext("sys"))',
'__doc__' : '(space.newtext("PyPy sys module"))',
'platform' : 'space.newtext(sys.platform)',
'maxint' : 'space.newint(sys.maxint)',
# 中略
'stdin' : 'state.getio(space).w_stdin',
'__stdin__' : 'state.getio(space).w_stdin',
'stdout' : 'state.getio(space).w_stdout',
'__stdout__' : 'state.getio(space).w_stdout',
'stderr' : 'state.getio(space).w_stderr',
'__stderr__' : 'state.getio(space).w_stderr',
# 略
}
このinterpleveldefs
というのはPython本体にはない概念です。PyPyのドキュメントに説明があります。
そこから参照されるラッピングに関する説明に、次のようなことが書かれています。
- PyPyには2つのレベルがある
- アプリケーションレベル: 通常のPythonのインタプリタに近い名前空間(space)
- インタプリタレベル: 名前空間(space)そのものをいじるなど、よりインタプリタ内部に根ざしたレベル
インタプリタレベルの関数は実行時にspaceへのバインドを持つとありますね。確かに、stdin
はspace
を引数にとるgetio()
の返り値であります。spaceはPyPyの名前空間そのものですね。
ちなみに、最初のprint_()
も実はこのappleveldefs
の仕組みを使って、print()
関数と、最初に説明したprint_
を結びつけられていました。
appleveldefs = {
'print' : 'app_io.print_',
}
getio()
getio
はこちらで定義されていました。
class IOState:
def __init__(self, space):
from pypy.module._file.interp_file import W_File
self.space = space
w_stdin = W_File(space)
w_stdin.file_fdopen(0, "r", 1)
w_stdin.w_name = space.newtext('<stdin>')
self.w_stdin = w_stdin
w_stdout = W_File(space)
w_stdout.file_fdopen(1, "w", 1)
w_stdout.w_name = space.newtext('<stdout>')
self.w_stdout = w_stdout
w_stderr = W_File(space)
w_stderr.file_fdopen(2, "w", 0)
w_stderr.w_name = space.newtext('<stderr>')
self.w_stderr = w_stderr
w_stdin._when_reading_first_flush(w_stdout)
def getio(space):
return space.fromcache(IOState)
space.fromcache
は次のところで定義されています。
class ObjSpace(object):
"""Base class for the interpreter-level implementations of object spaces.
http://pypy.readthedocs.org/en/latest/objspace.html"""
reverse_debugging = False
@not_rpython
def __init__(self, config=None):
# 略
self.fromcache = InternalSpaceCache(self).getorbuild
cacheという名前の通り、get or buildなので、インスタンスがなければビルドして、そうでなければキャッシュから返すという感じっぽいですね。まあ大きな流れには影響なさそうなのでここはスルーします。stdoutの定義は前述のIOState
から辿る必要がありますね。W_File
というクラスがポイントのようです。w_
という命名規則はアプリケーションレベルとインタプリタレベルの接点となるラッパーの意味です。
W_File
クラス
W_File
は次のコードで定義されています。
コンストラクタはたいしたことやっていないので、IOState
で呼ばれているfile_fdopen()
を見てみると、direct_fdopen()
を呼んでいますね。どんどんいきましょう。
def file_fdopen(self, fd, mode="r", buffering=-1):
try:
self.direct_fdopen(fd, mode, buffering)
except OSError as e:
raise wrap_oserror(self.space, e)
direct_fdopen
のコードは次のようになっています。この最後のfdopenstream()
はインスタンス変数に設定をちょこちょこ書き込んでいるだけなので、本命はstreamio.fdopen_as_stream()
っぽいですね。
def direct_fdopen(self, fd, mode='r', buffering=-1):
self.direct_close()
self.w_name = self.space.newtext('<fdopen>')
self.check_mode_ok(mode)
stream = streamio.fdopen_as_stream(fd, mode, buffering,
signal_checker(self.space))
self.check_not_dir(fd)
self.fdopenstream(stream, fd, mode)
この関数はここで定義されています。いままでは/pypy/module
以下だったのが、rpython/rlib
以下に移動しました。
/rpython/rlib/streamio
PyPyはPythonで書かれたPythonインタプリタというのを聞いたことがある人はいるでしょう。この前者のPythonというのが訳ありで、RPythonという特別なルールに限定された機能縮小版のPythonです。
ここで軽く説明しています。
Pythonではあるが、ネイティブコードに翻訳可能なレベルに限定されたPythonです。つまり、ここから下はネイティブコード化されるということですね。でもPythonのまま読めるというのがPyPyの特徴です。
fdopen_as_stream()
の実装は次のようになっています。
def fdopen_as_stream(fd, mode, buffering=-1, signal_checker=None):
os_flags, universal, reading, writing, basemode, binary = decode_mode(mode)
_check_fd_mode(fd, reading, writing)
_setfd_binary(fd)
stream = DiskFile(fd, signal_checker)
return construct_stream_tower(stream, buffering, universal, reading,
writing, binary)
construct_stream_tower()
は、ファイル入出力にバッファリングを追加する感じのように見えます。システムコールを頻繁に呼び出すとパフォーマンスが落ちるのである程度まとめて書き込んだり、読み込んだりするのを「バッファリングする」といいます。
OSへのアクセスはDiskFile
クラスですね。こちらもこのファイルの中に定義されています。こちらのwrite()
メソッドが最初のprint_()
で呼ばれるやつですね。os.write()
を呼んでいます。
def write(self, data):
while data:
try:
n = os.write(self.fd, data)
except OSError as e:
if e.errno != errno.EINTR:
raise
if self.signal_checker is not None:
self.signal_checker()
else:
data = data[n:]
os.write()
っていうと、アプリケーションレベルのos
パッケージにもどってしまっています。ネイティブコードからインタプリタに依存することはないはずなので、これから先はすべてrlibのはず。せっかくネイティブコードに戻ったのに、そんなはずはない、とrlibの中を見たら、rposix.pyにこんな関数がありました。
rlibの中のosパッケージはこちらの関数に置き換えられているようですね。歴史的経緯ですかね。
@replace_os_function('write')
@signature(types.int(), types.any(), returns=types.any())
def write(fd, data):
count = len(data)
with rffi.scoped_nonmovingbuffer(data) as buf:
ret = c_write(fd, buf, count)
return handle_posix_error('write', ret)
c_write()
ってのを呼んでいますね。だいぶ核心に近づいた感じがします。Windows以外だと次のような感じで定義されています。
c_write = external('write',
[rffi.INT, rffi.VOIDP, POSIX_SIZE_T], POSIX_SSIZE_T,
save_err=rffi.RFFI_SAVE_ERRNO)
external()
の定義は以下のようになっています。
def external(name, args, result, compilation_info=eci, **kwds):
return rffi.llexternal(name, args, result,
compilation_info=compilation_info, **kwds)
rffi周りについてはドキュメントにまとまっています。ここからlibcの関数にいって、OSへのシステムコールに変換されて文字列の出力になります。
libcはシステムコールを呼び出すためのC言語のラッパーライブラリです。ここでC言語かよ、と思われるかもしれませんが、C言語の標準ライブラリと違い、libcはかなり低レベルな関数集です。メモリ管理ともなるとlibcの中でもかなり複雑なことをしていますが、write()
ぐらいになると、OSのシステムコールとほぼ1:1です。Goの場合はここまで自前で実装していましたが、Go以外の多くの言語はWindows以外はlibcを使って実装されています。WindowsはWin32 APIとかを使っています。システムコールの番号も公式に固定されているわけではなく、各OSが保証しているのはlibcの関数を使えば動くよ、というだけですので、むしろGoが変わっています。あえてクロスコンパイルのためにそのリスクをかぶっています。
まとめ
PyPyのソースを見ながら、print()
から、libcの関数を呼び出すところまで辿っていきました。
なぜPyPyかという種明かしをすると、CPythonって高速化のためにほとんどがC言語で実装されているのです。ということは、Python本体のコードを見て学ぼうとするとC言語で学ぶに等しい。Cで学ぶのであればすでにいろいろ参考文献とかいろいろあるのであえてやる必要はないですよね?
他に似たようなことができる言語といえばRustですかね。Rustも次のラッパーライブラリでlibcをラップしており、それまでのところはRustで完結しているようです。まあRustはそれ以前の言語の知識で先に進むのがハードモードになりそうだし、今のところ仕事で触れるチャンスがなくて経験値が貯まってないので僕が同じようなことをするとしてもだいぶ先になりそうではあります。
Rustには、libc自身も自分で実装しようという実験的なプロジェクトがあるようなので、それが完了したら上から下までRustという世界を見ることもできそうです。楽しみですね。
あとはDartあたりも気になっていたりはしますが、僕にとって「実家」と言える言語はPythonだし、Pythonでやってみました。
明日は@r_rudiさんです。