※ 左右のカーソルキーでもページ繰りができます(但しブラウザ依存)
藤原 克則 ( FUJIWARA Katsunori )
受託開発主体の独立系ソフトウェアハウス数社を経て、現在フリーランス。
前職で、 HPC ( High Performance Computing ) 系システムのために Solaris 向けファイルシステムを実装したのを機に、 OpenSolaris 勉強会に参加。
「入門TortoiseHg+Mercurial」とか 「俺のコードのどこが悪い?―コードレビューを攻略する40のルール」、 「アセンブラで読み解くプログラムのしくみ」(電子書籍) といった書籍の執筆や、 技術系ウェブ媒体への記事の寄稿も。
ホームページ以外にも、 はてなダイアリー (id:flying-foozy) 等で情報発信中。 Twitter アカウント (@flyingfoozy) は細々と運用中。
本資料は、 "Solaris Internals" の第9章を、 各段落毎に「ワンフレーズ」化すること基本としています。
「ワンフレーズ」化対象の段落を識別するために、 以下のような識別情報表記を使用します。
[{章/節番号}/{通し段落番号}]@p.{ページ番号}
例えば、"[9.2.6/2]@p.465" は、 以下で始まる 「465 ページに配置されている、9.2.6 節における第2段落」 を指します。
As shown in the example, the program's address space comprises ...
列挙の各項目も1段落として扱います。 単一の列挙項目が複数の段落から構成されている場合は、 構成要素の段落を、それぞれ個別の段落として扱います。
識別情報の構成要素は、 紙媒体の "Solaris Internals Second Edition" を元にしています。
なお、個人的に興味深いと思った点に関しては、 勝手に掘り下げた話を展開します。
[9/1]@p.455
Solaris の仮想メモリ機構に関する実装を一通り概観します。
[9.1/1]@p.455 〜 [9.1/4]@p.455
SunOS 3 以前では、 旧来の BSD 様式なメモリ管理実装だったため、 可搬性がありませんでしたが、 SunOS 4.0 において、 以下のゴールを念頭に仮想メモリ機構を書き直しました。
個人的には、 学生時分に最初に触ったワークステーションが SunOS 4.x を載せた Sun3 だったので、 流石に SunOS 3 の頃の話には付いていけない (^ ^;;)
当時出回っていた「UNIX 4.3BSD の設計と実装」 (所謂「悪魔本」) では、 「copy-on-write」と「ページベースメモリ管理」について述べられているので、 "old BSD-style" の問題点は、 機能的な面ではなく、 「可搬性」とか「モジュラリティ」といった設計 (= design) 面での問題が主だったものと推測。
今は亡き「UNIX USER」誌の連載 「Truth of the Legend ― UNIX考古学」が単行本化していれば、 多分裏話とかも含めて確認出来ると思うのだが ....
SunOS 4.x 〜 Solaris に相当する 1990 年前後は、 「とりあえず何でもオブジェクトって言ってみる」とか 「頑張れば万能な基底クラスが出来る筈」とか、 非常に荒っぽい時期という印象が (個人的には) あるので、 ここで言う「オブジェクト指向」が当時どういったニュアンスだった (or どの程度野心的だった) かは気になるところ。
[9.1/5]@p.455 〜 [9.1/11]@p.456
メモリ管理における最重要オブジェクトは、 「セグメント」「vnode」「ページ」であるが、 以下のものも「メモリオブジェクト」として抽象化されています。
ここで言う「segment」は、 所謂 x86 アーキテクチャで言うところの「segment」とは全くの別物。 「記憶クラス(or 種別)」的なニュアンス。
管理方針/方式の異なるアドレス空間毎に、 カーネルモジュールとして segment device driver を実装 (= クラス定義) することで、 それぞれの方針/方式に応じた仮想空間を運用できる (= インスタンス生成) ようになっている。
クラスとインスタンスの関連付けは、 インスタンスに相当する struct seg 領域から、 クラス定義に相当する「関数テーブル」を参照することで実現 (この辺は、ファイルシステムと同様)。
struct seg
"as segments of mapped vnodes" の構造に関しては、 図 9.7 @ p.468 および図 9.10 @ p.483 が参考になるはず。
vnodes
[9.1/12]@p.456
現行 Solaris のメモリ管理機構は、 SunOS 4.0 でのリライト版を元にしており、 複数 CPU 構成でのパフォーマンス向上や、 可搬性に優れています。
[9.1/13]@p.457
物理メモリ管理は、 プラットフォーム固有の MMU ハードと、 固有ハードに応じた HAT (Hardware Address Translation) 層実装コードによって実現されます。
例えば、 Spitfire MMU (sfmmu) 固有の HAT 層実装は、 ON のソースツリー上では、 usr/src/uts/sfmmu/vm および usr/src/uts/{sun4|sun4u|sun4v}/vm 配下に見ることが出来る。
usr/src/uts/sfmmu/vm
usr/src/uts/{sun4|sun4u|sun4v}/vm
[9.1/14]@p.457
セグメントデバイスドライバは、 物理メモリと仮想アドレスとの対応付けを管理し、 仮想アドレス空間と物理メモリ間での関連付け (= 変換エントリの生成) を HAT 層に依頼します。
個々のアドレス空間は、 複数のセグメント (= 種別毎の連続メモリ) から構成され、 個々のセグメントは種別毎のセグメントデバイスドライバにより生成/管理される (図 9.7 @ p.468 および図 9.10 @ p.483 参照)。
セグメントドライバ実装は、 ON のソースツリー上では、 usr/src/uts/common/vm 配下に見ることが出来る。
usr/src/uts/common/vm
[9.2/1]@p.457 〜 [9.2/5]@p.457
プロセスのアドレス空間は fork() 契機で生成され、 主に以下の4つの部位から構成されますが、 空間全てが物理メモリにマッピングされているわけではありません。
fork()
"Executable text" は同一バイナリ由来のプロセス間での共有 (shared mapping)によりメモリ効率を高めているが、 デバッガでブレークポイントを設定する場合は、 そのプロセスの "Executable text" に関して専用コピーを生成した上で、 ブレークポイントの埋め込みが実施される。
共有ライブラリへのブレークポイント埋め込みも、 基本的には「当該プロセス用に専用コピーを作成」するのだが、 DTrace でのプローブ埋め込みの場合は、 「システム内で shared mapping されているテキスト領域を直接書き換え」るため、 libc.so とかにプローブを埋め込むと、 システムパフォーマンスが劇的に低下するので要注意。
そういえば、 昔は「初期化済み固定データ」は .rodata セクションに配置されていたのだけれど、 いつの間に .text に配置されるようになったのだろう?
.rodata
.text
JIT 的なことをやろうと思うと、 ヒープ領域のマッピングに実行権限を設定する必要がある。
[9.2/6]@p.458
実行可能テキスト/データはアドレス低位に、 ヒープはそれに続いて配置され、低位から高位に拡張される一方で、 スタックはアドレス高位に配置され、高位から低位に拡張されます。
「マップ外のアドレスへのアクセスはページフォルト要因」であることを利用して、 ヒープにわざとギャップを作っておくことで、 脆弱性への攻撃を失敗させる(≠ 実行継続が可能) 手法を採用しているのは、 BSD 系 OS だったかな?
プログラミングキャリアの最初の方で、 アドレス低位を上方に持ってくる図に慣れてしまったせいで、 未だにアドレス低位を下方に持ってくる図には違和感を感じてしまう。 日本の8ビット〜16ビットパソコン界隈は、 「アドレス低位を上」の図が多かった気がするのだが....
[9.2.1/1]@p.458 〜 [9.2.1/3]@p.458
Solaris カーネルにおける共有ライブラリは、 プロセスのアドレス空間におけるヒープとスタックの間にマッピングされます。
共有ライブラリや実行可能ファイルのテキスト部分では、 複数のプロセスから同一物理メモリが共有されます。
この辺に関する詳細は、segment driver についての説明の際に。
実行可能テキストにおける共有は、 各プロセスにおいてマッピングされるアドレスが固定である一方、 共有ライブラリはどの仮想アドレスにマッピングされるかは各プロセス毎に異なる。 これが位置非依存コード (position independent code: PIC) 化が必要な理由。
なお、[9.2.1/2]@p.458 では 「共有ライブラリ全体が複数プロセスで共有」されるかのような表現になっているが、 実際に共有されるのはテキスト部分だけ (private でマッピングしておいて改変が走った契機で複製する、 いわゆる copy-on-write の可能性はある)。
っていうか、この一連の突っ込み、 chapter 8 でも同じ事書いてた... orz
[9.2.2/1]@p.459 〜 [9.2.2/4]@p.459
SPARC システムでは、MMU の違いに応じてアドレス空間の配置が異なります。
"V7" ? 僕の記憶以外にも、 SPARC International のテクニカルドキュメント一覧とか、 wikipedia の SPARC を見るに、 "V8" の間違いのような気が ....
[9.2.2/5]@p.459 〜 [9.2.2/8]@p.461
SPARC V7 上のシステムでは、 アドレス高位にカーネル(権限保護付き)、 アドレス低位にユーザプロセス向けの領域が配置され、 アドレス空間を共有する形式となるため、 ユーザプロセスが利用可能なアドレスの範囲が限定されます。
SPARC V9 上のシステムでは、 カーネルとユーザプロセスは個別のアドレス空間を使用するため、 双方ともに利用可能なアドレス範囲の制限が無くなりました。
SPARC V9 では、 64 ビットアドレス空間の利用も可能ですが、 実際に利用可能なアドレス空間は 44 ビット程度に制限される場合があります。
全ての SPARC システムでは、 NULL ポインタ参照検出のため、 アドレス最低位の領域はマッピングされていません。
空間共有方式を採用しているアーキテクチャの MMU は、 何が原因で共有する羽目になっているのだろうか? あるいは単に、 当時の環境的には「共有した方が性能が良かった」だけ?
[9.2.3/1]@p.461
x64 のアドレス空間配置が SPARC V9/64 ビットカーネルとほぼ同じである一方、 x86 は、 SAPRC V7 と同様の「共有形式」をとりつつも、 スタック領域が確保される位置が異なります。
x86 のスタック領域がアドレス低位に確保されるのは、 Solaris 固有の話? それとも他の OS でも共通の、CPU 固有の話?
ネットの海を彷徨ってみたら、 x86 Linux でメモリ利用は、 1G + 2G + 1G 毎に領域が分断されるようなことを書いているページがあったけど、 教えて!詳しい人!
っつーか生成可能スレッド数は、 メモリ消費状況とかじゃなくて、 アーキテクチャ的に固定されてしまうってこと? > x86
⇒ 歴史的経緯に由来する ELF (or リンカ) の都合らしい
[9.2.4/1]@p.461 〜 [9.2.4/3]@p.462
ヒープ拡張そのものはページ単位に実施されるますが、 巨大なメモリを直接渡されても困るので、 malloc() ファミリによる汎用メモリ管理機能によって、 ページ単位の割り当ては隠蔽されています。
malloc()
ヒープの拡張は sbrk(2) によって実施されますが、 malloc() ファミリによって隠蔽されているので、 ユーザが直接 sbrk(2) を呼び出す必要はありません。
sbrk(2)
sbrk(2) による割り当て要求に対して、 仮想アドレスの空間だけを割り当てることで、 「割り当て領域への初回アクセス」⇒ 「対応する物理ページが無いので page fault」⇒ 「セグメントドライバによる物理メモリ割り当て」⇒ 「メモリ内容を zero-filled 化」という処理が、 ユーザプロセスから見て透過的に実現できます。
[9.2.4/4]@p.462 〜 [9.2.4/5]@p.462
malloc() 領域を free() しても、 単に領域に対して「使用可能」状態が設定されるだけなので、 (1) プロセスが終了するか、 (2) ページスキャナによって刈り取られない限り、 プロセスの割り当て物理メモリ量は増えこそしても減少することはありません。
free()
ヒープ領域の拡張は、 共有ライブラリの領域に掛かるまで可能ではありますが、 システム毎に上限が定められています。
共有ライブラリに割り当てられる空間と、 マルチスレッド実行でスレッド毎スタックに割り当てられる領域はどうなるのか?
64bit Solaris 上で 32bit 動作するプログラムを確認した限りでは:
[9.2.5/1]@p.462 〜 [9.2.5/2]@p.463
スタック領域の拡張 (アドレスの高位から低位への「拡張」) は、 malloc() や sbrk(2) 等によるヒープの拡張とは異なる原理に基づくものです。
関数呼び出しにおける「スタック消費」が、 スタック領域の初期割当量を超えた際の page fault 発生を契機に、 スタック領域の拡張が実施されます。
「スタック消費」は、引数/局所変数/戻りアドレス等の格納領域確保のため。
C コンパイラが、 入れ子になったブロック内で定義される局所変数のための領域の確保が、 オンデマンドで実施されるような場合は、 関数呼び出し以外の契機でもスタック消費が増える (あくまで「可能性」の話)。
レジスタウィンドウ (register window) の仕組みを持つ SPARC の場合、 関数の引数/戻りアドレスはレジスタ経由で渡せることになっているが、 window を使い切った際には再利用のために現行値の保存が必要なので、 スタック領域そのものは呼び出しの入れ子に応じて相応に「消費」してしまう。
細かい世界の話になると、 これ (= regsiter window が回り切った際の内容退避) も意外と馬鹿にならないオーバーヘッドだったりする。
[9.2.5.1/1]@p.463 〜 [9.2.5.1/2]@p.463
同一 vnode を指す seg_vn のマッピングが生成されることで、 各プロセス毎に異なるアドレスから、 ファイル内容を格納した同一の物理メモリを参照出来ます。
同一ファイルをマップする2つ目以降のプロセスは、ファイル中の必要なページにのみ attach し、これはマイナーフォールト (minor fault) として観測されます。
ここで言う「1つ目」「2つ目以降」の単位は、 ファイル単位ではなく、ページ単位。
各ページごとに、 初回アクセスなら「major fault ⇒ ファイルの対応部位の読み込み」、 2回目以降なら「minor fault ⇒ 読み込み済みページへの attach」が実施される。
[9.2.5.1/3]@p.463
複数プロセスで共有された状態における memory mapped file 改変時の挙動には、 改変内容を共有する (= ファイル内容に反映される) MAP_SHARED と、 改変プロセスからしか改変内容が見えない (= ファイルにも反映されない) MAP_PRIVATE の2種類があります。
MAP_PRIVATE でマッピングされた領域が「専用コピーを作成する」のは、 あくまで「書き込み」が発生してからなので、 「書き込み」前の段階であれば、 同一ファイルを MAP_SHARED でマッピングした別のプロセスが書き込んだ内容は、 MAP_PRIVATE でマッピングしたプロセス側でも観測可能。
----@
[9.2.6/1]@p.465 〜 [9.2.6/10]@p.466
pmap コマンドを使うことで、 プロセス毎のメモリマッピング状況を見ることが出来ます。
pmap
基本的な pmap 実行では、以下の情報が表示されます。
"s" が示す「共有」は、 「同一物理メモリを複数プロセスから参照」ではなく、 「内容改変時の伝播範囲」に関するものなので、要注意。
(※ コメント続き)
pmap を /lib/svc/bin/svc.startd に適用してみたところ、 マルチスレッドのスレッドスタック領域をはじめ、 結構なメモリが "R" 付きでマッピングされていた。
/lib/svc/bin/svc.startd
mmap(2) によれば、 MAP_PRIVATE なページへの書き込みが発生すると、 当該プロセス用の私的コピーの保持が必要になるので、 通常は mmap(2) 時点で swap 上に私的コピーの保持領域が予約されるとのこと。
mmap(2)
しかし、MAP_NORESERVE 付きだと、 swap 領域の事前予約をせず、実際に私的コピーの保持が必要になった段階で:
という挙動の切り替えが発生する。
svc.startd が "R" なマッピングを多様しているのは、 資源枯渇時に swap 獲得待ちでアレしたくないからか?
svc.startd の挙動モデルを把握していないので、 これに関しては個人的に要調査項目。
⇒ Solaris 上でスレッドを生成した場合、 固有スタック領域は MAP_NORESERVE で確保される。
Solaris スレッドモデルのスレッドを生成する thr_create() も、 POSIX スレッドモデルのスレッドを生成する pthread_create() も、 内部的には同一のスタック生成ロジック (find_stack() @ usr/src/lib/libc/port/threads/thr.c) を呼び出しており、 利用者が明示的にスタック領域を確保した場合以外の新規スタック割り当ては、 常に MAP_NORESERVE で確保している。
thr_create()
pthread_create()
CMT (Chip Multi Threading) などのスレッド多重度重視なハードウェアアーキテクチャの方向性と、 MAP_NORESERVE 指定のスタック確保でスレッドあたりのリソース保証量を減らすソフトウェアアーキテクチャの方向性には、 一貫性があると言えなくも無い。
[9.3/1]@p.466
VM 処理での組み込み DTrace プローブは少ないですが、 fbt(function boundary trace) プロバイダを使用すれば、 様々な情報を採取することが可能です。
本文中で、VM 系の主要な実行パスとして上げられている page_create() は、 usr/src/uts/common/vm/vm_page.c で定義されているが、 この関数は将来のメジャーリリースの際の廃止が宣言されている (ソース中のコメント参照) ものなので、 現状は page_create_va() 呼び出しのための wrapper でしかない。
page_create()
usr/src/uts/common/vm/vm_page.c
page_create_va()
開発/DTrace 適用の際には、 新しい I/F である page_create_va() を使用すべき。
[9.3/2]@p.466
単純に VM 全体をトレース対象にすると、 オーバーヘッドが物凄い事になってしまうため、 address space、segment および page レイヤーの I/F に絞ってトレースすべきです。
ところで、gvm.sh ってどこから来た話?
[9.3/3]@p.467
先の DTrace 適用例では、 munmap(2) の延長で、 ユーザプロセスのアドレス空間内における該当マッピングの引き当て (as_findseg()) 〜 対応するセグメントドライバによる処理 (segvn_unmap()) 〜 HAT 層機能の呼び出し (hat_unload_callback) という処理が発生している様子がわかります。
munmap(2)
as_findseg()
segvn_unmap()
hat_unload_callback
空間内でのマッピング状況は、 AVL 木構造で管理されている (9.4.1 の図 9.7 参照)。 引き当てを行う as_findseg() @ usr/src/uts/common/vm/vm_as.c は管理 AVL 木構造の走査を行うが、 AVL 木構造の機能はライブラリ化されているので、 実際の実装コードは非常に簡単。
usr/src/uts/common/vm/vm_as.c
ちなみに AVL 木構造を扱う機能実装は、 一応「for kernel use」と謳ってはいるが、 usr/src/common/avl/avl.c に集約されており、 このファイルをコンパイル&リンクすることで、 ユーザ空間でも AVL 木構造の機能を使用することができる。
usr/src/common/avl/avl.c
私自身の FS モジュール開発の際にも、 AVL 操作を行う機能部位の単体テストは、 テスト対象コード/テストプログラム/AVL 機能を、 ユーザアプリとしてリンク&実行することで、 テスト実行の容易化/自動化を図ったことがある。
[9.4/1]@p.467
アドレス空間は、 カーネルと各プロセス毎に用意され、 アドレス空間への改変に対する初期化/終了と共に、 アドレス空間で発生する MMU フォールトが管理されます。
[9.4.1/1]@p.467
アドレス空間管理モジュールは、 セグメントドライバの wrapper として機能するので、 アドレス空間管理を依頼する他のモジュールは、 セグメントドライバを意識する必要はありません。
図 9.7 における "struct proc" がユーザプロセスの管理構造体で、 "p_as" フィールドにより指されている "struct as" がアドレス空間 (Address Space) の管理構造体。
struct proc
p_as
struct as
"... and contains pointers to the segments that constitute the address space" に相当するのが、 AVL Tree による木構造のルートに相当する "a_segtree"。
a_segtree
ちなみに、図 9.7 には、幾つか間違いがある。
まずは、"Executable - Text" セグメントに相当する "struct seg" に対して、 "struct as" から参照線が直接出ていること。
as_findseg() において、 直前の検索結果をキャッシュするための a_seglast というフィールドは存在するが、 ここでの参照線は多分そういう意味では無い筈 (※ 何の記述も無くそんな参照を図に含めるのはちょっと考え難い)。
a_seglast
"Executable - Text" = アドレス空間先頭のセグメントへの参照を意味するとしても、 「アドレス最下位のセグメント」の引き当て実現は、 AVL Tree 機能を経由している筈。
もうひとつは、 "struct as" 枠に書かれている以下のフィールドは、 現状の定義には存在しない点。
a_nsegs
a_tail
a_watchp
後で引用される構造体定義を見ても、存在しないのは明らかだし、 修正履歴を見ても、これらに関する修正は存在しない。 どこでどう間違ったものか?.....
⇒ 予想通り、旧版の図がそのまま転載された模様。
[9.4.1/2]@p.468 〜 [9.4.1/10]@p.469
アドレス空間管理は、以下の機能を持ちます。
exit()
"page locking and advice for an address space" で触れている「advice」って、 madvise(3C) のことかなぁ? ⇒ YES
madvise(3C)
[9.4.1/11]@p.469
アドレス空間管理モジュールは、 他のモジュール向けに多数の機能を提供していますが、 それらの多くは、操作の必要なセグメントを特定し、 対応するセグメントドライバに処理を転送しています。
[9.4.1/12]@p.469 〜 [9.4.1/13]@p.469
アドレス空間を新規生成する as_create() は、 init プロセス起動時にのみ呼び出され、 それ以降のアドレス空間生成は、 fork(2) 契機での as_dup() による複製で実施されます (スタック/ヒープもこの時点で複製)。
as_create()
fork(2)
as_dup()
子プロセス側での exec(2) 即実行 (= アドレス空間の作り変え) を想定している vfork() の場合、 子プロセスは親のアドレス空間を借りて実行を継続し、 exec(2) 契機でアドレス空間の割り当て/設定を行う一方、 アドレス空間を子プロセスと共有している間は親プロセス側は待ち状態となります。
exec(2)
vfork()
通常の (カーネル側) システムコール実装が usr/src/uts/common/syscall/ 配下で定義されているのと違い、 fork(2) 実装は usr/src/uts/common/os/fork.c で定義。
usr/src/uts/common/syscall/
usr/src/uts/common/os/fork.c
『アドレス空間を借りて』実行していることから、 子プロセス側でのメモリ改変結果は親プロセス側でも観測可能。 以下、vfork(2) のマニュアルより引用。 "during this time" は、 『子プロセスでの exec(2) ないし exit(2) 実行により、 vfork(2) から復帰するまでの間』を指す。
Any modification made during this time to any part of memory in the child process is reflected in the parent process on return from vfork() or vforkx().
[9.4.1/14]@p.470 〜 [9.4.1/15]@p.470
DTrace で採取した、 brk(2) 契機によるメモリ要求における処理の流れを以下に示します。
brk(2)
ページフォールト発生時に呼ばれる as_fault() は、 as_segat() でアドレスに対応するセグメントの引き当てを行い、 失敗した場合は SIGSEGV シグナルを送付し、 成功した場合は当該セグメントのドライバにフォールト処理を要求します。
as_fault()
as_segat()
本文中では as_setat() となっているが、 正しくは as_segat()。
as_setat()
ちなみに、 as_findseg() と as_segat() は、 どちらもアドレスを指定してセグメントを引き当てる関数だが、 以下のような違いがある:
findseg
segat
[9.4.1/16]@p.470
セグメント機能の辞書順一覧を表 9.3 に示します。
[9.4.2/1]@p.472
struct as の a_lock による排他を保持したまま実施される処理の場合、 処理終了が一定時間を越える可能性があるなら、 呼び出しコンテキストで直接処理を実施するのではなく、 コールバックを登録して、 イベント発生契機で実際の処理を実施する、 という手法が望ましい (例: NFSv3 における VOP_DELMAP 実装)
a_lock
「NFSv3 における VOP_DELMAP 実装」でのコールバック使用は、以下の流れ:
as_unmap()
nfs3_delmap()
nfs3_delmap_callback()
上記の一連の流れにおいては、 EAGAIN 復帰は、 「正常終了したが、コールバック呼び出しが必要」 という特別の意味を与えられる模様。
ちなみに、コールバック機能を使用しているのは ファイルシステム系では NFS 実装のみ。 物理デバイスの「I/O 再試行」等による応答待ちは許容範囲、 という判断なのかな? > 他のファイルシステム
[9.4.2/2]@p.472
as_free()、 as_setprot() や as_unmap() は、 実行されたコールバックに対する as_delete_callback() が実施されるまでは、 次の処理に進まずに待ち状態でいてくれるので、 非同期処理による資源解放等を、 アドレス空間処理のコールバック機構を用いて待ち合わせることも可能です。
as_free()
as_setprot()
as_delete_callback()
この段落、随分解釈に迷ったので、検討過程を詳細にまとめておく。
まず、コールバックの種類は以下の3種類:
AS_FREE_EVENT
AS_SETPROT_EVENT
AS_UNMAP_EVENT
本文中に記載のある as_do_callbacks() が呼ばれるのは、 実は as_free() からのみ (@snv_134)。
as_do_callbacks()
as_do_callbacks() の実際は、 as_find_callback() と as_execute_callback() を組み合わせているだけ。
as_find_callback()
as_execute_callback()
他の局面 (= setprot/unmap)では、 直接これらの下請け関数を呼び出している。
このような構成になっているのは、 setprot/unmap においては、 コールバック呼び出し前に a_lock 排他の解放といった、 個別事情に応じた処理を差し込む必要があることで、 as_do_callbacks() を直接再利用できないため。
前段落 ([9.4.2/1]@p.472) のコールバック使用例での処理の流れは、 a_lock 排他を主軸に説明したが、 アドレス空間管理情報を参照/改変する際に必要な排他である a_contents を主軸に見ると、 以下のような流れになる。
a_contents
cv_wait()
呼び出されたコールバックは通常、 処理の一環として自分自身を as_delete_callback() する (e.g.: nfs3_delmap_callback()) が:
といった手順を踏めば、 非同期処理の終了待ち合わせを as_execute_callback() 側に任せてしまうことも可能。
[9.4.3/1]@p.473 〜 [9.4.3/5]@p.473
Solaris において、 アドレス空間の各部位 (= 各セグメント) を異なる方式で扱えるのは、 MMU ハードウェが以下の保護モードを提供していることが前提となっています。
保護の実現は、セグメントおよび HAT 層で実施されます。
[9.4.4/1]@p.473
MMU は、 カーネルの介在無しには処理の継続が出来なくなった場合、 「メジャーページフォールト」 (major page fault)、 「マイナーページフォールト」 (minor page fault) および 「プロテクションフォールト」 (protection fault) の3種類に大別されるトラップによって実行中のプロセスを中断し、 対応するメモリ管理処理を実施します。
[9.4.4/2]@p.473 〜 [9.4.4/3]@p.474
メジャーフォールトは、 「セグメントによってマップされている」メモリ領域へのアクセスにおいて、 「対応する物理ページへのマップ (= MMU エントリ) が存在しない」かつ 「物理メモリ中に対応するページが存在しない」状況を指し、 (1) 新規にページを割り当てるか、 (2) swap 領域に退避されているページを読み込んでくる、 といった対応が実施されます。
マイナーフォールトは、 「セグメントによってマップされている」メモリ領域へのアクセスにおいて、 「MMU エントリが存在しない」が、 「物理メモリ中に対応するページが存在する」状況を指し、 "attach" と呼ばれる事もあります。
フォールト発生時の状態と対応する挙動を、 カルノー図として以下にまとめてみた。
[9.4.4/4]@p.474
プロテクションフォールトは、 事前の許可設定に違反したアクセスがあった場合に、 MMU ハードウェアによってトラップとして生成され、 各セグメントのフォールト処理によって対処されます。
[9.4.4/5]@p.474 〜 [9.4.4/13]@p.475
アドレス空間、セグメント、ハードウェア MMU の関係を図 9.8 に示します。
図 9.8 は、プロセス中のヒープ領域に対するアクセスが、 対応する物理ページを持たなかったケースを示したもので、 メモリ枯渇の際にページスキャナによって物理ページが取り上げられてしまった、 という状況に相当します。
これらの処理が完了したなら、プロセスは実行を再開する事が出来ます。
[9.5/1]@p.476 〜 [9.5/3]@p.477
セグメントは、 アドレス空間と任意デバイスとの間でのマッピング管理を抽象化します。
ファイルをマッピングするセグメントが、 ファイル内容のキャッシュとしてメモリを使用する一方で、 ハードウェアをマッピングするセグメントは、 フレームバッファデバイスのようなデバイスを空間に貼り付けるなど、 セグメントによって挙動は異なりますが、 ユーザプロセスからは同じようにアドレス空間へアクセス出来ます。
セグメントによる抽象化のお陰で、 実際のアクセス対象に関わり無く、 リニアなアドレス空間を提供するための仕組みが提供されます。
[9.5/4]@p.477 〜 [9.5/6]@p.478
セグメントドライバには、最低でも (1) マッピング生成、(2) フォールト処理および (3) マッピングの破棄の機能が要求されます。
新たにマッピングを生成したい場合、 セグメントの新規生成処理関数を引数に指定 (= セグメント種別を意識) として as_map() を呼び出しますが、 一旦セグメントが生成されてしまえば、 以後の当該セグメントに対する操作では、 そのセグメントの種別を意識する必要は無くなります。
as_map()
処理の例を示すと、 mmap(2) 契機での処理の流れは、 (1) segvn_create() を引数にして as_map() が呼ばれ、 (2) segvn_create() 呼び出しにより生成されたセグメントが、 (3) struct as 管理構造に組み入れられます。
segvn_create()
「生成」や「破棄」に関する処理は、 いつの時代になっても頭の痛い話。 デザインパターンで生成系を抽象化するパターン (e.g.: Factory, Abstract Factory, Prototype...) が(人によっては)難解なのと根は一緒か。
処理の流れに関して、もう少し正確に列挙すると:
mmapobj()
usr/src/uts/common/os/mmapobj.c
VOP_MAP()
[9.5/7]@p.478
セグメントの機能を呼び出す場合、 関数テーブル経由で呼び出しを行う SEGOP_FAULT() のようなマクロを使うため、 呼び出し元はセグメントの種別を意識する必要がありません。
SEGOP_FAULT()
この辺りは、 ファイルシステムにおける VOP_○○○ マクロによる呼び出しと同じ手法。
VOP_○○○
[9.5/8]@p.479 〜 [9.5/9]@p.479
Solaris は、 最も利用頻度の高いファイルベースのマッピングを行うためのセグメントドライバ seg_vn 以外にも、 カーネルメモリに関するマッピングや、 ハードウェアデバイスをマッピングを行うものなど、 多数のセグメントドライバによって成り立っています (表 9.4 参照)。
Solaris 10 におけるセグメントドライバが提供する機能の一覧を、 表 9.5 に示します。
[9.5.1/1]@p.481 〜 [9.5.1/7]@p.481
最も広く使用されるセグメントドライバ seg_vn は、 物理メモリをキャッシュとして使用しつつ、 vnode に対応するファイルをアドレス空間に貼り付けますが、 ヒープ/スタックのための anonymous 領域や、 共有メモリ (non-ISM) 機能の提供といった事も行います。
seg_vn ドライバは、以下のような貼り付けを管理します。
[9.5.1.1/1]@p.481 〜 [9.5.1.1/3]@p.482
mmap(2) 契機で生成された vnode セグメントは、 アドレス空間管理下に置かれ、 アドレス変換設定やマップ対象におけるフォールト処理を管理します。
seg_vn によって生成される vnode セグメントの場合、 セグメントを管理する struct seg の s_data フィールドの参照先は struct segvn_data 領域で、 ここに保持される vnode 経由でファイルシステムが殆どの処理を行うため、 seg_vn 自身の処理は貼り付けの生成/破棄等に絞られたものとなります。
s_data
struct segvn_data
ファイルの貼り付けが行われると、 vnode や offset などが struct segvn_data 領域に格納されます (少々複雑な anonymous メモリに関する詳細は、後述します)。
抽象化構造体のフィールド ○○○_data の先に各実装毎の固有情報格納領域がある、 という構造は、 ファイルシステムにおける struct vnode と v_data フィールドの関係と同一。
○○○_data
struct vnode
v_data
[9.5.1.1/4]@p.482
mmap(2) の延長で、 当該ファイルシステムの VOP_MAP() 実装 (e.g.: UFS なら ufs_map()) が呼ばれ、 これを契機に seg_vn のセグメント生成関数 segvn_create() によるセグメントの生成が行われます。
ufs_map()
[9.5/4]@p.477 〜 [9.5/6]@p.478 のまとめも参照のこと。
[9.5.1.1/5]@p.482
この時点で HAT 層の hat_map() を呼び出すことで、 仮想アドレスのマッピング (= MMU エントリ) を生成するので、 物理ページが割り当てられるまでは、 貼り付け対象領域へのアクセスはページフォールト要因となり、 セグメントドライバのページフォールト処理が実行されます。
hat_map()
segvn_create() 中から hat_map() 呼び出しが実施されるのは、 (1) MAP_PRIVATE 且つ (2) TEXT データ且つ (3) ファイルのマッピング且つ (4) 権限が「ユーザによる読み出し/実行」且つ (5) ハードウェア制約が満たされる場合に限定 (以下の use_rgn が 1 の場合のみ)。
use_rgn
if (a->type == MAP_PRIVATE && (a->flags & MAP_TEXT) && a->vp != NULL && a->prot == (PROT_USER | PROT_READ | PROT_EXEC) && segvn_use_regions) { use_rgn = 1; }
(5) の「ハードウェア制約」とは "shared region" なるもののサポートらしいのが、 これがナニモノなのか誰か知ってる?
以下は、segvn_init() 中のコメント:
segvn_init()
currently significant benefit from text replication was only observed on AMD64 NUMA platforms (due to relatively small L2$ size) and currently we don't support shared regions on x86
ぱっと見、キャッシュ/メモリアーキテクチャ依存の話っぽいんだけど....
本文で言うところの「established MMU mapping」とは、 希少資源である TLB エントリのことではなく、 通常メモリ上に置かれる TSB エントリのことを指しているのかな?
調べようと思って、 sfmmu や i86pc の hat_map() 実装を見てみたら....空っぽだ! *.c にダミーの関数エントリを置いて、 実装コードはアセンブラというパターンかと思ったけど、 *.s ファイルも含めて、usr/src/uts 配下には実装コードが無い! どーゆーこと? > Solaris
[9.5.1.1/6]@p.483 〜 [9.5.1.1/7]@p.484
MMU によるアドレス変換の関連付けが確立されたなら、 当該セグメントへの初回アクセスによるフォールトは、 as_fault() 経由で seg_vn ドライバに通知され、 segvn_fault() 〜 VOP_GETPAGE() の流れでファイル内容が読み込まれますし、 それ以後のアクセスは、 ファイル内容が読み込まれた物理メモリ (= キャッシュ) への直接アクセスに変換されます。
segvn_fault()
VOP_GETPAGE()
マップドファイルへの書き込みの場合、 変更内容を書き出すイベントが存在しないため、 変更内容は即座には (ストレージ上の) ファイルに反映されませんが、 flush デーモンによりページの改変が検出されたのを契機に、 ファイルシステムの VOP_PUTPAGE() により書き出しが実施されます。
VOP_PUTPAGE()
「変更内容は即座には (ストレージ上の) ファイルに反映されません」が、 デバイスとのダイレクト I/O 要求をしない限り、 ファイル I/O は mmap(2) で使用されるキャッシュ領域へのアクセスとなるため、 単にファイルを cat した場合は、 「即座に反映されている」ように見える筈。
cat
[9.5.2/1]@p.484 〜 [9.5.2/3]@p.484
Copy-on-Write では、 MAP_PRIVATE なマッピングを、 MMU 上は read-only、セグメント上は read-write と設定しておき、 ページフォールト (この場合は protection fault) 処理時に、 この設定が成立する場合に Copy-on-Write 処理を実施します。
フォールトが発生したページの vnode 共有状態を解消し、 anonymous メモリを同一アドレスに貼り付けた上で、 オリジナルのファイル内容を複製します。
Copy-on-Write の局面では、 通常であれば anonymous メモリとして新たな物理ページを割り当てますが、 空きメモリが minfree を下回った場合には、 ファイルの別な場所に使用されている物理ページの再利用で済ませます。
minfree
[9.5.3/1]@p.484 〜 [9.5.3/3]@p.485
seg_vn ドライバが管理するセグメントでは、 segvn_data 構造の pageprot がゼロの場合、 セグメント全体が同一の設定 (prot フィールド値) で保護されますが、 pageprot が非ゼロの場合は、 ページ単位で異なる設定の保護が可能です。
segvn_data
pageprot
prot
ページ単位保護が適用される場合、 segvn_data 構造の vpage は、 セグメントを構成するページ数分だけの struct vpage 要素を持つ配列を参照します。
vpage
struct vpage
ページ単位保護で使用される struct vpage 領域は、 ページ毎の advice 情報の保持にも使用されます。
[9.6/1]@p.485 〜 [9.6/4]@p.485
プロセスのヒープ/スタック領域や、 Copy-on-Write で使用する、 ファイルと直接関連付いていない匿名メモリ (anonymous memory) は、 匿名メモリ層 (anonymous memory layer) と swapfs ファイルシステムにより管理されています。
ヒープ領域や、/dev/zero を MAP_PRIVATE マップしたセグメントでは、 メモリ領域への初回アクセスが発生した時点で、 segvn_fault ハンドラによって ZFOD (Zero-Fill-On-Demand) されたページが動的に割り当てられます。
seg_vn は、 匿名メモリ層 I/F 経由で取得した匿名メモリを、 segvn_data の amp メンバ配下の構造で管理します。
初期段階では、 メモリ管理配列 (slot) 自体が存在しませんが、 初回メモリアクセス〜メモリ割り当ての時点で、 フォールト発生アドレスに該当するスロットの領域が確保され、 該当スロットに割り当てたページへの参照が格納されます: ページが再利用のために回収された場合、 スロット領域自体はそのままですが、 ページへの参照がなくなります。
[9.7/1]@p.xxx 〜 [9.7/2]@p.xxx
anonymous なセグメントでは、 最初のページフォルト契機で、 以下の構造を作成します。
{struct anon_map} | +- ahp ->{struct anon_hdr} | +- array_chunk ->{ポインタ配列領域} | +-->{struct anon}
必要とされるメモリ領域サイズ(= struct anon の数)に応じて、 『ポインタ配列領域』を1段だけ経由して struct anon を参照する場合と、 『ポインタ配列領域』を2段経由して参照する場合があります。
struct anon
『ポインタ配列領域』の最大サイズは PAGESIZE なので、 1段間接参照で管理可能な最大メモリサイズは、 格納可能なポインタ数 * PAGESIZE で算出可能。
2段間接参照で(単一セグメントにおける)管理可能な最大メモリサイズは:
x64 アーキテクチャのセグメントサイズ、 思ったより大きく出来ないなぁ。 やっぱり large page 前提なんだろうか……?
struct anon_hdr から参照される 『ポインタ配列領域』領域のサイズは、 PAGESIZE きっちりではなく、 初回要求サイズ分のみ。
struct anon_hdr
anon_create()@usr/src/uts/common/vm/vm_anon.c ahp->size = npages; /* 割り当て済みサイズは『要求ページ数』 */ /* ANON_CHUNK_SIZE はページ毎に格納可能なポインタ数 */ if (npages <= ANON_CHUNK_SIZE || (flags & ANON_ALLOC_FORCE)) { /* 1段間接の場合は、要求ページ数分だけ確保 */ ahp->array_chunk = kmem_zalloc(ahp->size * sizeof (struct anon *), kmemflags); } else { /* * 2段目のポインタ配列は必ず1ページ単位で割り当てるので * 割り当てサイズをページ毎格納数で切り上げる */ ahp->size = P2ROUNDUP(npages, ANON_CHUNK_SIZE); /* 2段目のポインタ配列を参照可能なサイズの算出 */ nchunks = ahp->size >> ANON_CHUNK_SHIFT; /* 要求ページ数の管理に必要な分だけ確保 */ ahp->array_chunk = kmem_zalloc(nchunks * sizeof (ulong_t *), kmemflags); /* 2段目のポインタ配列割り当ては遅延させる */ }
struct anon_hdr からの struct anon 参照取得の際には、 size によって間接参照の段数を判定している。
size
anon_get_ptr()@usr/src/uts/common/vm/vm_anon.c if ((ahp->size <= ANON_CHUNK_SIZE) || (ahp->flags & ANON_ALLOC_FORCE)){ /* 1段間接の場合 */ } else { /* 2段間接の場合 */ }
[9.7/3]@p.xxx 〜 [9.7/4]@p.xxx
SVR4 実装では、 『ポインタ配列領域』中の各スロットには、 対応する仮想アドレスへの物理ページ割り当て有無に応じて、 ページ管理構造体 (page_t?) ないし NULL が設定されますが、 Solaris の実装では、 vnode へのポインタと offset 値を保持するようになっています。 但し、ここで保持される "physical backing store" の vnode は、 実際の swap 領域のものではありません。
page_t
anon 層の API は Table 9.6 に一覧化してあります。
struct anon を用いた間接的な管理構造は、 スワップ実装方式の隠蔽や、 物理的な退避位置の流動化のためと思われる。
典型的な例が、 "9.2.6 Using pmap to Look at Mappings" の際に触れた、 MAP_NORESERVE 付きでの anon メモリ確保などの場合。
MAP_NORESERVE 付きで確保されたメモリは、 swap 領域の事前予約を行わないため、 実際に swap out されるまでは swap デバイス上の書き出し先が確定しておらず、 事前に実 swap 領域の vnode/offset 等を保持することができない。
[9.8/1]@p.xxx 〜 [9.8/3]@p.xxx
Solaris では vnode + offset で物理メモリを識別しており、 物理メモリ上に対応するデータが無い場合の、 アクセス先ファイルシステムに対応する vnode が使用されます。
anonymous メモリの退避先として使用する上で、 スワップ領域に十分な空きがあることを保証するためには、 ヒープ/スタック/書き出し可能ファイルの MAP_PRIVATE での mmap(2) 等の際に、 あらかじめスワップ領域を予約する必要があります。
Solaris では、 スワップ領域の予約無しでも anonymous メモリを確保できるので、 十分にメモリがある場合は、 少量のスワップ/スワップ無しでの運用も可能です。
MAP_NORESERVE 付きでの mmap(2) 実施により、 スワップ予約無しの anonymous メモリが確保可能(9.2.6 も参照)。
MAP_NORESERVE
[9.8/4]@p.xxx 〜 [9.8/5]@p.xxx
伝統的な UNIX システムでは、 8MB の領域を malloc(3) するには、 将来的な使用の有無に関わらず、 8MB のスワップ領域予約が必要なので、 『スワップ領域は物理メモリ量の倍程度』 というマジックナンバーが使われてきました。 Solaris の swapfs では、 『物理メモリよりも多く使いたい分』 のスワップを確保するだけでも大丈夫です。
malloc(3)
Solaris の swapfs は擬似的なファイルシステムなので、 スワップ領域(= デバイス上の領域)が割り当てられていなくても、 あたかもスワップ領域が割り当て済みであるかのように振る舞う事が可能です。
個人的な経験では、 エンドユーザから 『ファイルシステムアクセスの応答速度が低下している』 との報告を受けて調査したら、 8 core / 32GB メモリ程度のシステムで、 スワップが 1GB 程度しか確保されておらず、 完全なメモリ不足状態だった、 という事例もあったので、 『メモリが十分』の見極めには十分な注意が必要。
そういえば、 最近の HDD は物理セクタが 4K になったけど、 x86/x64 アーキテクチャのページサイズが 4K だから、 さすがに物理セクタをこれ以上大きくすることはないよね?
[9.8.1/1]@p.xxx 〜 [9.8.1/2]@p.xxx
十分な物理メモリ/物理スワップ領域があるなら、 仮想的なメモリ領域をプールから割り当てることで、 仮想的なスワップ領域(= 物理媒体と関連付いていないスワップ) の予約は成功します。
プライベートセグメントの割り当て要求は、 スワップ予約と anon 構造体の割り当てのみが実施されます。 物理ページのフォルト契機で ZFOD (Zero-Fill-On-Demand) や COW (Copy-On-Write) でページが作成された際には、 仮想スワップ(= swapfs)の vnode が物理ページの識別に使用されます。
anon
[9.8.1/3]@p.xxx 〜 [9.8.1/5]@p.xxx
セグメントドライバからの anon_alloc() 〜 swapfs_getvp() 〜 swapfs_getpage() 呼び出し契機で、 swapfs の vnode/offset を使って anonymous ページが生成されます。
anon_alloc()
swapfs_getvp()
swapfs_getpage()
この段階では、 使用可能な仮想スワップ領域は減少しますが、 ページの書き出し(swap-out)が必要無いので、 物理的なスワップ領域の割り当ては実施されません。
最初のページ書き出しでは、 anonymous ページの vnode (= swapfs の vnode) による VOP_PUTPAGE() 呼び出しにより swapfs_putpage() が起動され、 このタイミングで物理的なスワップ領域の割り当て/ 物理スワップ領域に対応する vnode/offset の設定が実施されます。
swapfs_putpage()
[9.8.1/6]@p.xxx
物理的スワップ領域が枯渇した場合、 anonymous ページに対する VOP_PUTPAGE() 実施でも物理ページが解放されないため、 当該物理ページが張り付いた状況になり、 処理の継続のために本来必要なページがメモリに読み込まれなくなります。
[9.9/1]@p.xxx 〜 [9.9/3]@p.xxx
Solaris のアドレス空間(as)が提供する virtual memory watchpoint は、 一般的な breakpoint と異なり、 メモリ領域への READ/WRITE 契機でもプロセスの実行が停止されます。
virtual memory watchpoint は、 アドレス+領域サイズ指定を使って、 /proc 経由で設定/解除されます。
/proc
prwatch_t 構造体の、 対象領域の先頭仮想アドレス(pr_vaddr)、 領域サイズ(pr_size)、 挙動指定(pr_wflags) を使って, virtual memory watchpoint を指定します。
prwatch_t
pr_vaddr
pr_size
pr_wflags
[9.9/4]@p.xxx
pr_wflags が非ゼロの場合は、 pr_vaddr + pr_size による設定の追加が、 pr_wflags がゼロの場合は、 pr_vaddr 一致領域に対する設定の解消が実施されます。
設定解消が pr_vaddr 一致のみで判定されるとすると、 先頭が重複する複数の領域に対する設定とか、 同一領域に対する pr_wflags 非ゼロでの連続設定は、 どのように扱われるのか?
ソースを見た限りでは、 procfs の prwritectl() ⇒ pr_watch() ⇒ set_watched_area() (@usr/src/uts/common/fs/proc/prsubr.c) において、 『厳密一致以外は領域重複を許さない』判定が実施されている模様。 ----@
prwritectl()
pr_watch()
set_watched_area()
[9.9/5]@p.xxx 〜 [9.9/7]@p.xxx
対象プロセス中の LWP による watchpoint 領域の参照により trap (FLTWATCH) が発生し、 SIGTRAP が送信されます。
種別が WA_TRAPAFTER の場合は、 命令実行後=メモリ改変後に、 そうでない場合は実行前=メモリ改変前にトラップが発生します。
watchpoin を踏んだ場合のデフォルト挙動はコアダンプです。
/proc/*/status 経由で、 フォールト(=TRAP ?)のトレース設定が可能な模様。 "set of traced signals" とは独立して "set of traced faults" が設定できるので、 シグナルと混同しているのではない筈 (man proc(4) 参照)。
/proc/*/status
man proc(4)
ちなみに /proc/*/status 経由で、 システムコールの呼び出し/復帰のトレース設定も可能みたい。 この辺は POSIX 標準とは無縁の無法地帯だから、 逆にアンテナを張っておいた方が面白そう。
この前後の段落は、 man proc(4) での記述とほぼ同じ(丸写し?)。
FLTWATCH がトレースされていない場合のコアダンプは、 SIGTRAPが送信されるため(man signal.h(3HEAD) 参照)
man signal.h(3HEAD)
[9.9/8]@p.xxx
/proc 経由の pr_info から、 watchpoint 情報を入手可能。
pr_info
pr_info は、 pstatus_t from /proc/PID/lwp/LWPID/lwpstatus、 あるいは pstatus_t from /proc/*/status の pr_lwp 経由で参照可能。
pstatus_t
/proc/PID/lwp/LWPID/lwpstatus
pr_lwp
流石に、 man proc(4) の方が、 Solaris Internals よりも詳細な情報が書かれている。
考察:
TRAP 命令埋め込みベースでの breakpoint 実現の場合:
Virtual Memory Watchpoint ベースでの breakpoint 実現の場合:
[9.10/1]@p.xxx
Solaris では、 複数ページサイズ対応(Multiple Page Sizes for Solaris: MPSS)の一環として、 large MMU page のサポートを提供しています。
[9.10.1/1]@p.xxx 〜 [9.10.1/2]@p.xxx
Solaris のラージページ対応は、 (1)仮想メモリ機構への影響の局所化しつつ、 (2)通常サイズのメモリ操作における性能を維持しています (但しswapfs へのインパクトは除く)
個々のラージページは、 物理ページの最少単位である PAGESIZE(SPARC は 8K, x86 は 4K)領域の連続で構成され、 ラージページのサイズと同じアドレス境界で整合しているものとします。 PAGESIZE の物理メモリ毎の管理情報を保持する page_t 構造体の p_szc (サイズコード)フィールドの値は、 物理ページのサイズを表し、 SPARC であれば 0=8K, 1=64K, 2=512K, 3=4M (x86 であれば 0=4K, 1=32K, 2=256K, 3=2M)を意味します。
p_szc
任意の『ラージページ』において、 それを構成する物理ページに対応する全ての page_t は、 ラージページ先頭からのオフセット等に関わらず、 p_szc が同じサイズコードを保持する点に注意。
[9.10.1/3]@p.xxx
page_t に対応する物理メモリを『可変サイズ』とした場合、 page_lookup() において 『指定の vnode/offset の組に相当するページ』の有無を調べる際に、 ページサイズ違いのハッシュ値を試す必要が生じるなど、 効率が低下してしまします。
page_lookup()
ページサイズ可変の場合、 『仮想空間における、ページ先頭のオフセット』は、 そのページのサイズに応じて変動。 そのため、 vnode/offset で指定されたアドレスが属するページの先頭位置は、 サイズコードのバリエーションと同じ数だけ候補が存在する。
そのため、 最悪の場合はサイズコードのバリエーションと同じ数だけ、 問い合わせ処理を繰り返さなければならない。
Solaris で採用している方法の場合、 page_t に対応するページはPAGESIZE固定なので、 問い合わせは一度で済む。
また、 引き当てた page_t の p_szc が 0 以外=ラージページの構成要素だった場合も、 『連続領域』+『ラージページのサイズ境界』なので、 『ラージページの先頭』は簡単に引き当てることができる。
[9.10.2/1]@p.xxx 〜 [9.10.2/2]@p.xxx
空きページの管理リストは、 有効なデータを保持するページを管理する cache list と、 有効なデータを保持していないページを管理する free list の2つの論理的なリストから構成されます。 ラージページのファイルマッピングをサポートしないので、 cache list のサポート対象は PAGESIZE 固定です。
free list と cache list は、 カラー(外部キャッシュを物理メモリサイズで割ったもの)や NUMA グループ(locality group)毎に分割されます。
カラー判定は SPARC 限定?
typedef struct page { 〜〜〜〜 #if defined(__sparc) uchar_t p_vcolor; /* virtual color */ #else uchar_t p_embed; /* x86 - changes p_mapping & p_index */ #endif 〜〜〜〜 } page_t;
[9.10.3/1]@p.xxx 〜 [9.10.3/5]@p.xxx
ラージページに関するフォールトを、以下の側面から説明します。
[9.10.3.1/1]@p.xxx〜 [9.10.3.1/2]@p.xxx
各セグメントのセグメント構造体における、 s_szc (size code)フィールドに保持される『推奨ページサイズ』は、 SEGOP_SETPAGESIZE (システムコール経由なら memcntl(2))による事後変更か、 segvn_create() での生成契機で設定されます。
SEGOP_SETPAGESIZE
memcntl(2)
指定ページサイズのメモリが割り当てできない場合は、 空き待ちのブロックを回避するために、 指定ページサイズが割り当て可能になるまで待たずに、 順次(最悪は PAGESIZE まで)割り当てページサイズを切り下げて割り当てます。
書籍であげられているカーネル変数 mpss_brkpgszsel や mpss_stkkpgszel は、 illumos の最新ソースベースには存在しない。
mpss_brkpgszsel
mpss_stkkpgszel
usr/src/uts/common/os/exec.c における、 上記変数とそれに付随するコードは、 2006-10-26 における変更で破棄された模様 (元々 #ifdef DEBUG なコードっぽい)
usr/src/uts/common/os/exec.c
#ifdef DEBUG
ざっと見た限りでは、 現行のコードに『ランダムにページサイズを設定』するコードは見当たらない
コマンド的には ppgsz(1) で、 推奨ページサイズ(preferred page size)の設定が可能。
ppgsz(1)
『full associateive な TLB なら、 要求よりも1つだけサイズダウンしたページで、 割り当て待ちした方が良い』とあるのは何故か?
[9.10.3.2/1]@p.xxx〜 [9.10.3.2/2]@p.xxx
MAP_PRIVATE な anonymous ページは、 仮想スワップ FS である swapfs(≠ swap デバイスの FS)上に割り当てられるので、 該当の anonymous ページが物理メモリ上に見当たらない場合、 swapfs の VOP_GETPAGE() 経由で、 バックストア(= 実 swap デバイス)から、 新規に割り当てられたメモリに読み込まれます。
MAP_PRIVATE
(vnode, offset) 対でアクセス対象となるメモリを指定する、 現行の VOP_GETPAGE() 呼び出し(+ swapfs の実装)では、 PAGESIZE ページの取り扱いしか想定していないため、 単一 VOP_GETPAGE() 呼び出しでは、 ラージページを扱うことができません。
[9.10.3.2/3]@p.xxx〜 [9.10.3.1/4]@p.xxx
ANON層は、 anonymous ページを一意に管理するため、 struct anon の割り当て毎、 つまり PAGESIZE サイズのページ毎に、 仮想的な (vnode, offset) 対を swapfs から割り当ててもらいます。 つまり、 ラージページを構成する連続した PAGESIZE ページの個々に対して、 swapfs が同一の vnode を割り当てるとは限りませんので、 (vnode, offset) によって読み込み対象を指定する VOP_GETPAGE()では、 ラージページ全体に対する一括VOP_GETPAGE()発行が、 できないのです。
更に、 『ラージページの先頭は、 ラージページサイズで境界整合していなければならない』 という制約があるため、 現状の (vnode, offset) による対象指定のスキームでは、 swapfs が割り当てる仮想的な (vnode, offset) は、 ラージページに適切ではありません。
最終的に swapfs_getvp() で割り当てられる、 仮想 vnode は、実質的にランダムな vnode が割り当てられることになる。
anon_alloc()@usr/src/uts/common/vm/vm_anon.c: ap = kmem_cache_alloc(anon_cache, KM_SLEEP); /* struct anon* ap */ if (vp == NULL) { swap_alloc(ap); →→ swap_alloc(AP)@usr/src/uts/common/sys/swap.h: | (AP)->an_vp = swapfs_getvp(((uintptr_t)(AP) >> AN_CACHE_ALIGN_LOG2) \ | & AN_VPMASK); \ →→ swapfs_getvp(ulong_t vidx)@usr/src/uts/common/fs/swapfs/swap_subr.c: | vp = swap_vnodes[vidx]; | if (vp) { | return (vp); | }
swapfs による仮想 offset も、 struct anon のポインタ値を使った、 ビットシフトによって決定される。 ----@
[9.10.3.2/5]@p.xxx
ラージページに対する Page-In を扱うシンプルな方法として、 ANON層で事前に確保したラージページ領域上で、 PAGESIZE 毎に VOP_GETPAGE() することで、 データを読み込むというものがあります。 swapfs 側では、 該当ページを含む既存のラージページの有無を確認し、 存在する場合は既存のものを優先して、事前確保したラージページの解放を行い、 存在しない(= 小さなページのものしか存在しない)場合は、 既存のページを事前確保したラージページに寄せて、 既存のページを解放します。
本文中では、 『thread 構造体(kthread_t ?)の t_vmdata リール度が云々』という記述があるが、 これまた illumos の最新ソースベースには存在しない。
kthread_t
t_vmdata
segvn_fault() 〜 segvn_fault_anonpages() 〜 anon_map_getpages() あたりの流れが、 ここでの処理の記述に該当すると思われる。 が、あまりちゃんと読み込めてない……orz
segvn_fault_anonpages()
anon_map_getpages()
[9.10.3.3/1]@p.xxx〜 [9.10.3.3/2]@p.xxx
MAP_PRIVATE で anonymous なページは、 対応する anon 構造体を持ち、 参照カウントで管理されていますが、 ラージページを構成する PAGESIZE 単位の各ページにも、 対応する anon が存在します。
ANON 層でのラージページにおける COW フォルトの際には、 ラージページの一部だけが共有されることを、 確実に防ぐ必要があります。 この制約によって、 ラージページの解放判定が簡素化できます。 また、COWフォルトで、十分なラージページが確保できない場合は、 同量の小さいページを集めて、 COWを実現します。
つまり、 ラージページは複製も解放も、 (仮に相応の連続物理ページが確保できない場合であっても) ラージページ単位で実施される、 という制約を導入することで、 参照管理の煩雑化等を防いでいると思われる。
[9.10.3.4/1]@p.xxx〜 [9.10.3.4/2]@p.xxx
DMA 等の I/O の間、 ページとMMU設定のロックを行う、 旧来の as_fault(F_SOFTLOCK) は、 連続した処理において都度ロック/ロック解除を行うと、 性能劣化要因(lookup 由来?)となるため、 ページリスト化と、 キャッシュを行うようになりました。
as_fault(F_SOFTLOCK)
新規実装では、 ロックしたページはセグメントのページキャッシュでキャッシュされ、 ページリストは buf(9S) 系フレームワークにより処理されます。 ページリストはMMU設定のロックを不要にし、 ページキャッシュは同一ページへの反復的 I/O を効率化します。
segvn_pagelock() 〜 seg_pinsert() あたりのコードが shadow list による page cache (pcache) か?
segvn_pagelock()
seg_pinsert()
shadow list は、 いわゆる LRU(least recently used)的なキャッシュを指しているのかな?
buf(9S)系フレームワークは、 uiomove(9F) 等の複数ページへの 一括I/O要求的なものを指している?
uiomove(9F)
[9.10.3.4/3]@p.xxx〜 [9.10.3.4/4]@p.xxx
segvn_pagelock() によって shadow list を page cache に追加した場合、 availrmem を相当ページ数分だけ減少させます。 ラージページの構成ページは『部分解放』ができないことから、 ラージページでのI/Oにおいて、 構成ページの一部に対する排他は、 ラージページ全体の排他と等価になります。 segvn_pagelock() では、 排他対象ページがラージページの構成ページだった場合、 排他対象をラージページ全体に拡張した上で、 availrmem を減少させます。
availrmem
ラージページ全体への排他対象拡張をせずに、 単にページサイズ情報だけで排他を行った場合、 ラージページの一部ページに対する as_pagelock() 発行の都度、 availrmem が減少されてしまいます。
as_pagelock()
アドレス空間/先頭アドレス/範囲を指定された as_pagelock() は、 対象セグメントを引き当てた上で、 SEGOP_PAGELOCK() 経由で segvn_pagelock() を起動する。
SEGOP_PAGELOCK()
排他範囲の内部管理は『先頭アドレス+範囲』ベースなので、 ラージページ全体への排他対象拡張をしないと、 個々の構成ページに対する排他は、 それぞれ別物として扱われる。
[9.10.4/1]@p.xxx〜 [9.10.4/4]@p.xxx
多くの場合、 ラージページは通常ページと同様に扱われますが、 以下の点が異なります。
msync(3C)
PAGESIZE
mlock(3C)
anonymous ページの内容書き出しにおいて、 単発 VOP_PUTPAGE() が使用できない件に関しては、 swapfs による仮想 vnode/offset 割り当て(『9.10.3.2 Page-In』)参照。
『ラージページのファイルマッピングをサポートしないので、 cache list のサポート対象は PAGESIZE 固定』 (『9.10.2 Free List Organization』)であることから、 解放されたラージページは cache list には入らない。
少々戻るが、 『9.10.2 Free List Organization』の原文 『we do not support large pages for mapped files』で言うところの mapped file は、 MAP_SHARED 限定の話か? そうでないと、 次節での『Consider the example of a process using 512-Kbyte pages for a MAP_PRIVATE /dev/zero mapping in its address space』が意味不明になってしまう。
MAP_SHARED
[9.10.5/1]@p.xxx〜 [9.10.5/2]@p.xxx
ラージページに対する操作は、 通常ページサイズへの操作と透過的に実施できますが、 ページ境界に沿わない、 プロテクション設定とマッピング解消に関しては、 ラージページ構成の解消が発生します。
MAP_PRIVATE により /dev/zero をラージページでマッピングしたプロセスにおいて、 ラージページを構成する 8K ページに対して munmap(2) や mprotect(2) を発行した場合、 この操作を検出した segvn セグメントドライバは、 対象ページを含むラージページを PAGESIZE 長(szc == 0)のページに分割(=降格)した上で、 『再実行』を要求する IE_RETRY を返却することで、 as レイヤによるページ排他の再取得(解放〜獲得)& 処理の再実行を促します。
mprotect(2)
IE_RETRY
『ページサイズの管理権限』は、 as レイヤではなく、セグメントレイヤ側にある。
segvn セグメントにおける処理で、 IE_RETRY を返却しているのは以下の処理:
SEGOP_UNMAP()
segvn_fault_vnodepages()
segvn_setprot()
SEGOP_SETPROT()
segvn_setpagesize()
SEGOP_SETPAGESIZE()
segvn_advise()
SEGOP_ADVISE()
MADV_ACCESS_DEFAULT
MADV_ACCESS_LWP
MADV_ACCESS_MANY
[9.10.5/3]@p.xxx〜 [9.10.5/4]@p.xxx
ラージページの格下げには、 セグメントの分割(結合も?)=セグメント一覧操作が必要なので、 アドレススペースに対する writer 排他が必要です。 元々 writer 排他が必要だった SEGOP_UNMAP() と違い、 reader 排他でも呼び出せる SEGOP_SETPROTO() の場合、 対象ラージページの分割等は行わず、 『writer 排他獲得後の再実行』を要求します。
SEGOP_SETPROTO()
/proc 経由での watch point 設定は、 PAGESIZE 単位でのアクセスになるため、 対象となったラージページは、 先述した手順により細分化されます。
[9.10.6/1]@p.xxx〜 [9.10.6/3]@p.xxx
MPSS(Multiple Page Sizes for Solaris)前の UltraSPARC 向けの Solaris sfmmu (SpitFire MMU) 実装は、 ユーザ側(=ユーザ向けアドレススペース?)では 8K/4MB ページTSBのみがサポート対象でした。 ユーザ dTLB ミス時の TTE (Translation Table Entry) 充填では、 4MB および 8KB TSB 検索の上で、 4MB および 8KB の HME (Hardware Mapping Entry) ハッシュテーブル検索が実施されます。
MPSS 対応後は、 パフォーマンス上の考慮から 8KB および 4MB ページ向けの TSB のみが構築され、 64KB/512KB ページ向けには 8KB ページ向けの TSB を流用します。
64KB TSB エントリは 8 つの、 512KB TSB エントリは 64 の 8KB TSB エントリを消費するため、 (1) TSB reach が、8KB/64KB/512KB で同一 (2) 64KB/512KB な TSB エントリの構築/破棄にコストが掛かる、 というデメリットがあります。 『ページサイズ毎 TTE 数』をHAT層で管理することで、 パフォーマンス影響が非常に高い TSB ミス処理において、 不要な検索(=使用していないページサイズに対する TSB 検索)を抑止しています。
ISM は 4MB ページ向けに最適化されている (=『ラージページ使用』を性能クリティカルとみなしている?) ため、 HME 検索は 4MB ⇒ 8KB の順で実施。
8K TSB ポインタが『ハードウェア生成』で、 4MB TSB ポインタが『ソフトウェアによる算出』なのは、 MPSS 前後で変化している?
→ UltraSPARC 固有の SPARC V9 拡張では、 ハードウェアによる TSB 検索アシストは、 8KB/64KB ページ向けのみに限定されている ("UltraSPARC User's Manual - UltraSPARC-I/-II" の "6.9 MMU Internal Registers and ASI Operations" における TSB 8KB/64KB ポインタの説明参照)。
[9.10.7/1]@p.xxx
procfs(1) の xmap ファイル経由で、 各ページのマッピングサイズを取得することができます。
/* usr/src/uts/common/sys/procfs.h */ /* * HAT memory-map interface. /proc/< pid >/xmap */ typedef struct prxmap { uintptr_t pr_vaddr; /* virtual address of mapping */ size_t pr_size; /* size of mapping in bytes */ char pr_mapname[PRMAPSZ]; /* name in /proc/< pid >/object */ offset_t pr_offset; /* offset into mapped object, if any */ int pr_mflags; /* protection and attribute flags (see below) */ int pr_pagesize; /* pagesize (bytes) for this mapping */ ....
MDB に関する説明なので割愛