この記事は「Ylang:eBPF、Stap+、GDB などに適用可能な汎用言語」シリーズの第 2 回です。他の回は第 1 回第 3 回第 4 回をご参照ください。

第1回の続きとして、本稿では Ylangの C 言語に対する様々な拡張機能の詳細について解説いたします。

言語文法(前回からの続き)

マクロ拡張

Ylangは、C 言語のマクロを処理するために、C プリプロセッサの全ての命令に対応しており、GNU 拡張もサポートしています。#if#elif#define#ifdef#ifndef などに加え、可変引数マクロ(variadic macros)も即座に処理することが可能です。Ylangコンパイラは、GCC との 100% の互換性を確保するため、Ylangソースコード内のマクロ命令の処理に GCC のプリプロセッサを直接使用しています。

さらに、Ylangは動的トレーシング時のコード再利用のために、独自のプリプロセッサ命令も多数サポートしています。例えば:

##ifdeftype SBufExt
#include "new.y"
##else
#include "old.y"
##endif

##ifdeftype 命令は Ylang独自のプリプロセッサ命令で、指定された C/C++ データ型が対象プロセスやアプリケーションに存在するかどうかを確認します。この例では、対象(または「トレシー」)に C/C++ データ型 SBufExt が存在する場合、Ylangソースファイル new.y が取り込まれます。通常、OpenResty XRay は、集中管理されたソフトウェアパッケージデータベースから型情報を抽出し、Ylangコンパイラに透過的に提供します。このデータベースには、DWARF などの形式のデバッグシンボルから事前に収集されたデバッグ情報が格納されています。

Ylang独自の命令名は、C プリプロセッサの命令との衝突を避けるため、単一の # ではなく ## で始まります。##ifdeftype のような Ylang独自の命令では、C 言語の対応する命令ではなく、##elif##else##endif などの対応する命令を使用する必要があります。

Ylang では、シンボルに関連する他の命令も採用されており、その多くは非常に直感的に理解できます。

##ifdeffield my_type my_field
##ifdeffunc do_something
##ifdeftype struct bar_s
##ifdefenum error_codes
##ifdefvar my_var

トレーサー空間とトレーシー空間

Ylangでは、メモリ管理に「トレーサー空間」(tracer space)と「トレーシー空間」(tracee space)という2つの異なる空間が存在します。トレーサー空間のメモリは動的トレーシングツールやアナライザ自体に常駐し、書き込み可能です。一方、トレーシー空間のメモリは追跡対象のプロセス(またはカーネル)に常駐し、厳密に読み取り専用となっています。トレーシー空間は「ターゲット空間」とも呼ばれます。読み取り専用のトレーシー空間メモリにより、Ylangツールやアナライザが対象プロセスの状態を1ビットも変更しないことが保証されます。さらに、トレーシー空間(またはトレーサー空間)で無効なアドレスを読み取っても、対象プロセスがクラッシュすることは 決して ありません。このような不正な読み取りは、トレーサー空間でエラーが発生するか、不正なデータが返されるだけです。

デフォルトでは、Ylangのすべての変数宣言または変数定義はトレーサー空間内にあります。例:

int a;
long b[3];
double *d;
struct Foo foo;

トレーシー空間では、_target キーワードを使用して変数を 宣言 する必要があります。例:

_target int a[5];

この行は、トレーシー空間内で配列型の変数 a を宣言しています。トレーシー空間は読み取り専用であるため、トレーシーまたは対象に既に存在する変数のみを宣言できます。Ylangコンパイラは、OpenResty XRay のソフトウェアパッケージデータベースから、追跡対象の変数シンボルに関する情報を自動的に検索します。

Ylangで使用されるデータ型は、デフォルトでトレーシー空間から取得され、_target キーワードは不要です(実際、使用すると Ylangコンパイラから構文エラーが返されます)。

トレーサー空間の複合型

Ylangトレーサー空間 で複合型の変数を宣言することができます。例:

void foo(void) {
    struct foo a;
    a.name = "John";
    a.age = 32;
    printf("name: %s, age: %d", a.name, a.age);
}

ここで struct foo 型はトレーシー空間から取得され、Ylangは OpenResty XRay ソフトウェアパッケージデータベースの支援を受けて、自動的に解析を試みます。

多くのオープンソースの動的トレーシングフレームワーク(例:SystemTapGDBDTraceなど)では、トレーサーランド(tracer-land)の複合型変数を定義する際に、煩雑で侵襲的な方法を必要とします。一方、Ylangにはそのような制限はありません。

プローブ

あらゆる動的トレーシング言語において、プローブの定義は最も重要な言語構造の一つです。Ylangは、ユーザー空間とカーネル空間の両方において、多数のプローブポイントをサポートしており、そのサポート範囲は継続的に拡大しています。

ユーザー空間プローブ

対象プロセス内の任意の C 言語関数のエントリーポイントにダイナミックプローブを設置することが可能です。プローブが起動されるたびに、以下のコードブロックが実行されます。関数エントリープローブを定義するには、_probe キーワードを使用し、対象関数の名前を指定します:

_probe main() {
    printf("main func is called!\n");
}

ここでは、対象プロセスの main() 関数エントリーにプローブを定義しています:

トレーサーランド内では、任意のパラメータを宣言し、参照することが可能です:

_probe ngx_http_finalize_request(ngx_http_request *r, ngx_int_t rc) {
    printf("uri: %.*s, status code: %d\n", (int) r->uri.len, r->uri.data, rc);
}

ここでは、ターゲットプロセスで定義された C 関数 ngx_http_finalize_request のパラメータ rrc からデータを出力しています。

関数リターンプローブ

任意のユーザー空間関数に対してリターンプローブを定義することも可能です。例えば:

_probe foo() -> int {
    printf("foo() func returned!\n");
}

また、戻り値を参照することも可能で、以下のように戻り値変数を宣言するだけです:

_probe foo() -> int a {
    printf("foo is returning %d\n", a);
}

もちろん、対象プロセス内のインライン関数や末尾再帰最適化された関数は、これらのリターンプローブを発生させません。なぜなら、これらの関数は「返らない」からです。少なくとも、通常の意味での「戻る」動作をしません。

その他の動的トレースフレームワーク

注目すべき点として、他の多くの動的トレーシングフレームワークでは、関数リターンプローブのサポートが欠如しているか、実装に欠陥があります。例えば、GDB には関数リターンプローブ(またはブレークポイントと呼ばれる機能)の組み込みサポートがなく、ユーザーは対象関数の各リターン位置に手動でブレークポイントを設定する必要があります。また、eBPFSystemTapBpftrace などのカーネルの uretprobes メカニズムに依存するものには、設計上の本質的な問題があり、ターゲットプロセスのランタイムスタック(stack)を変更・破壊し、stack unwinding などの多くの機能を損なう可能性があります。

幸いなことに、たとえターゲットがこのようなバックエンドであっても、または OpenResty XRay が提供するターゲットの拡張版(Stap+ や OpenResty XRay 独自の eBPF 実装など)であっても、Ylang はこのような制限を自動的に回避することができます。

カーネル空間プローブ

Ylangは、ユーザー空間のターゲットプロセスを分析でき、カーネル空間内の多くのプローブもサポートしています。

プロセススケジューラプローブ

オペレーティングシステムのプロセススケジューラの「CPU-on」および「CPU-off」イベントを検出することができます:

_probe _scheduler.cpu_on {
    int tid = _tid();
    printf("thread %d is on a CPU.\n", tid);
}

_probe _scheduler.cpu_off {
    int tid = _tid();
    printf("thread %d is off any CPUs.\n", tid);
}

これらのプローブポイントはオペレーティングシステムのカーネル内のプロセス/スレッドスケジューラー内に位置し、したがってカーネル空間に存在します。例えば、OpenResty XRay の off-CPU フレームグラフアナライザーは、Ylang と同じプローブを使用しています。

パフォーマンスアナライザーのプローブ

CPU 使用率のパフォーマンス分析には、以下のように _timer.profile プローブポイントを使用することができます:

_probe _timer.profile {
    _str bt = _ubt();  /* user-land backtrace string */

    /* do aggregates on the bt string value here... */
}

ここでは、Ylang の組み込み関数 _ubt() を使用して、現在のユーザー空間のバックトレース情報を文字列(Ylang の組み込み型 _str)として抽出します。OpenResty XRay の標準アナライザーは、同じ Ylang プローブポイントを使用して、様々な種類の on-CPU フレームグラフを生成します。

GDB や ODB などの純粋なユーザー空間トレースバックエンドでは、カーネル空間プローブを使用すると、コンパイル時にエラーが発生します。これらのバックエンドは設計上、カーネル空間にアクセスできません。

タイマープローブ

one-off タイマーや定期的なタイマーのように、指定した時間にプローブイベントを発生させることは非常に便利です。Ylang では、_timer.s および _timer.ms プローブ名を通じて、このようなプローブポイントをサポートしています。以下にいくつかの例を示します

_probe _timer.s(3) {
    printf("firing every 3 seconds.\n");
}

_probe _timer.ms(100) {
    printf("firing after 100ms");
    _exit();  // quit so that this timer is a one-off.
}

標準の eBPF ツールチェーンではこのようなタイマープローブをサポートしていませんが、Ylang ではユーザー空間でコードを生成し、適切にそれらをシミュレートすることが可能です。

システムコールプローブ

システムコール(syscall)の監視も可能です。例えば:

_probe _syscall.open {
    // ...
}

ここでは open システムコールを監視しています。

プロセス開始・終了プローブ

Ylangではユーザー空間プロセスの開始時と終了時にプローブを配置することも可能です。例えば:

_probe _process.begin {
  // ...
}

_probe _process.end {
  // ...
}

Ylang アナライザーまたはツールの実行開始時に対象プロセスがすでに実行中の場合、それらのプロセスがスリープ状態であっても、_process.begin プローブハンドラーは一度だけ、かつ確実に実行されます。

DWARF に依存しない解析

YlangOpenResty XRay のソフトウェアパッケージデータベースを使用して、変数、複合型のフィールドオフセット、任意のターゲット関数のエントリーポイントとリターン位置の、メモリアドレスとオフセットを検索します。これらの検索は通常、シンボル名を通じて行われます。そのため、ユーザーがバイナリの複雑な世界に深く関わる必要はありません。また、ターゲットシステム(通常は本番システム)のバイナリファイルや独立した .debug ファイルにデバッグシンボルやシンボルテーブルを含める必要もありません。この方法により、CTF や BTF などの他のデバッグ情報フォーマットや、機械学習アルゴリズムによってバイナリ実行ファイルから自動的に導出された情報も使用することができます。

拡張変数型

利便性を考慮して、Ylang は Perl や Python などの動的言語で一般的な変数型を用いて C 言語を拡張しています。実際、Ylang は Perl 6 言語と SystemTap のスクリプト言語から一部の記号を借用していることがわかります。

内置字符串

Y 语言は、従来のC言語の文字列もサポートしていますが、組み込みの文字列サポートも提供しています。この組み込み文字列は便利で、Ylang の多くの組み込み関数は、C 言語の文字列ではなく、この組み込み文字列に対して動作します(ただし、C 言語の文字列を組み込み文字列に変換することも可能で、これはトレース対象のプロセス空間からでも、時には暗黙的にも行えます)。

組み込み文字列型の名称は _str で、C 言語文字列とは異なり、文字列長を明示的に記録し、ペイロード内での null 文字 (\0) の使用を許可します。ただし、安全性を確保するため、文字列データの末尾には常に null 文字が含まれます(文字列長にはカウントされません)。

当然ながら、組み込み文字列は Ylang 独自のデータ型であるため、トレーサー空間でのみ割り当てることができます。

ユーザー定義関数において、_str 型を引数の型や戻り値の型として使用することができ、トレーサー空間のグローバル変数や自動変数としても使用できます。以下に例を示します:

_str foo(_str a) {
    _str b = a + ", world";
    printf("b = %s (len: %d)", b, _len(b));
    return b;
}

内置の文字列を連結する場合は、+ 演算子をオーバーロードして使用できることにご注意ください。

トレーシー空間の C 言語文字列をトレーサー空間の組み込み文字列に変換するには、Ylang の組み込み関数 _tostr() を使用します。

_target char *p;
...
_str s = _tostr(p);

ただし、トレーシーランド(tracee land)は設計上読み取り専用であるため、逆方向の変換はできません。

トレーシーランドでの文字列処理には、組み込み文字列を使用することが最適な方法です。Ylang では、プレフィックス/サフィックス/正規表現マッチング、部分文字列の抽出など、組み込み文字列を操作するための多くの組み込み関数を提供しています。

Ylang のユーザー定義関数で組み込み文字列をパラメータとして使用する場合、関数呼び出しは 参照 による文字列の受け渡しとなり、値のコピーは発生しません。

組み込み集約

集約データ型は、SystemTap の統計データ型と非常に類似しています。DTraceBpftrace などの他のトレースフレームワークでも、同様のデータ型が提供されています。集約は、最小値、最大値、平均値、合計、カウント、分散などのオンラインデータの集計と統計を計算する際に、メモリと CPU を非常に効率的に利用する方法を提供します。また、データ値の分布を視覚化するためのヒストグラムの計算と出力も可能です。以下に簡単な例を示します:

_agg my_agg;

_probe foo() -> int retval {
    my_agg <<< retval;
}

_probe _timer.s(3) {
    long cnt = _count(my_agg);
    if (cnt == 0) {
        printf("no samples found.\n");
        _exit();
    }
    printf("min/avg/max: %ld/%ld/%ld\n", _min(my_agg), _avg(my_agg), _max(my_agg));
    _print(_hist_log(my_agg));  // print out the logrithmic histogram from my_agg
}

以下は簡単な対数ヒストグラムの例です:

value |-------------------------------------------------- count
 1024 |                                                   0
 2048 |                                                   0
 4096 |@@@@@@@                                            7
 8192 |@                                                  1
16384 |@@@                                                3
32768 |                                                   0
65536 |                                                   0
65536 |                                                   0

OpenResty XRay では、このようなテキストグラフを自動的に美しい Web チャートとして表示することができます:

Web Chart Sample for a Histogram Generated by <a href="https://doc.openresty.com/en/xray/ylang/">Ylang</a>

集計内に記録されているすべてのデータをクリアするには、Ylang_del プレフィックス演算子を使用します:

_del my_agg;

組み込みの集計は、パラメータを通じて Ylang のユーザー定義関数に渡すことができます(ただし、戻り値としては使用できません):

void foo(_agg a) {
    // ...
}

関数呼び出しでは、集計型のパラメータは常に 参照 によって渡されます。

組み込み配列

Ylang は変数に対して組み込みの配列型を提供しています。C 言語における配列の扱いが困難であることは周知の事実です。組み込み配列変数は、Perl 6 言語と同様に @ シギルを使用します。以下は簡単な例です:

void foo(void) {
    _str @a;  // define a tracer-land array with the element type _str
    _push(@a, "world");  // append a new _str typed elem to @a
    _unshift(@a, "hello");  // prepend an elem to @a
    printf("array len: %d\n", _elems(@a));  // # of elems in array @a
    printf("a[0]: %s, a[1]: %s", @a[0], @a[1]);  // output the 1st and 2nd elems
}

この例では、組み込み文字列型(_str)の要素を持つ配列を定義しています。intdouble、ポインタ型、さらには複合型など、任意の要素型を定義することが可能です。

また、組み込み配列の先頭または末尾から要素を削除することもできます:

int @a;
_push(@a, 32);
_push(@a, 64);
_push(@a, -7);
int v = _pop(@a);  // v gets -7
v = _shift(@a);  // v now gets 32

組み込み配列を反復処理するには、従来の C 言語の for ループ文を以下のように使用することができます:

_str @arr;
// ...
int len = _elems(@arr);  // cache the array len in a local var
for (int i = 0; i < len; i++) {
    printf("arr[%d]: %s\n", @arr[i]);
}

また、ユーザー定義関数のパラメータとして組み込み配列を渡すことも可能です:

void foo(int @a) {
    // ...
}

関数呼び出しでは、組み込み配列型の引数はすべて 参照 によって渡されます。

配列内のすべての要素をクリアし、長さを 0 にリセットするには、Ylang の前置演算子 _del を使用することができます:

_del @arr;

組み込みハッシュテーブル

Ylang には組み込みのハッシュテーブル型も用意されています。組み込み配列と同様に、組み込みハッシュ変数もシギル(特殊記号)を使用しますが、異なる記号を用います。それは % です(Perl 6 と同じです)。組み込み文字列をキーとし、整数値を値とするハッシュテーブルを宣言するには、以下のように記述できます:

int %ages{_str};

ハッシュのキーと値には、あらゆるデータ型を使用することができます。

新しいキーと値のペアを挿入する場合は、以下のように記述します:

%ages{"Tom"} = 32;

既存のキーに対応する値を検索する場合は、以下のように記述します:

int age = %ages{"Bob"};

ただし、キーが存在するかどうか不確かな場合は、まず Ylang の前置演算子 _exists を使用してテストを行うべきです:

if (_exists %ages{"Zoe"}) {
    int age = %ages{"Zoe"};
}

キーの存在が不確実な場合は、事前に存在性をテストすることを推奨します。これは以下の理由によります:1. Ylang の一部のバックエンド(GDB Python など)では、キーが存在しない場合、実行時例外が発生する可能性があります。2. その他のバックエンド(Stap+ や SystemTap など)では、同様の状況で暗黙的に整数の 0 または浮動小数点値を返す場合があります。

ユーザー定義関数のパラメータを通じて、組み込みハッシュテーブルを渡すことも可能です。例えば:

void foo(int %a{_str}) {
    // ...
}

組み込みハッシュテーブル型のパラメータは、常に 参照 渡しで関数に渡されます。

ハッシュテーブルからキーを削除するには、Ylang_del 演算子を使用します:

_del %my_hash{my_key};

また、キーを指定せずに、ハッシュテーブル全体をクリアすることもできます:

_del %my_hash;

組み込みハッシュテーブルを反復処理するには、特別な _foreach ループ文を使用できます:

_foreach %my_hash -> _str name, int age {
    printf("%s: %d\n", name, age);
}

Perl 6 をご存知の方には、このループ構造が馴染み深いものと思われます。この構文は Perl 6 から着想を得ています。

続く

以上が第二回で紹介させていただく内容となります。ここで一旦中断させていただきます。第三回では、Ylang のさらなる特徴と利点についてご説明させていただきます。

著者について

章亦春(Zhang Yichun)は、オープンソースの OpenResty® プロジェクトの創始者であり、OpenResty Inc. の CEO および創業者です。

章亦春(GitHub ID: agentzh)は中国江蘇省生まれで、現在は米国ベイエリアに在住しております。彼は中国における初期のオープンソース技術と文化の提唱者およびリーダーの一人であり、Cloudflare、Yahoo!、Alibaba など、国際的に有名なハイテク企業に勤務した経験があります。「エッジコンピューティング」、「動的トレーシング」、「機械プログラミング」 の先駆者であり、22 年以上のプログラミング経験と 16 年以上のオープンソース経験を持っております。世界中で 4000 万以上のドメイン名を持つユーザーを抱えるオープンソースプロジェクトのリーダーとして、彼は OpenResty® オープンソースプロジェクトをベースに、米国シリコンバレーの中心部にハイテク企業 OpenResty Inc. を設立いたしました。同社の主力製品である OpenResty XRay動的トレーシング技術を利用した非侵襲的な障害分析および排除ツール)と OpenResty XRay(マイクロサービスおよび分散トラフィックに最適化された多機能ゲートウェイソフトウェア)は、世界中の多くの上場企業および大企業から高い評価を得ております。OpenResty 以外にも、章亦春は Linux カーネル、Nginx、LuaJITGDBSystemTapLLVM、Perl など、複数のオープンソースプロジェクトに累計 100 万行以上のコードを寄与し、60 以上のオープンソースソフトウェアライブラリを執筆しております。

翻訳

英語版の原文と日本語訳版(本文)をご用意しております。読者の皆様による他の言語への翻訳版も歓迎いたします。全文翻訳で省略がなければ、採用を検討させていただきます。心より感謝申し上げます!