JITの使用方法

ここに画像の説明を入力してください







一部の内部システムでは、BadooでJITを使用して大きなビットマップをすばやく検索します。 これは非常に興味深いトピックであり、最も有名なトピックではありません。 そして、このような厄介な状況を修正するために、Eli BenderskiによるJITの概要とその使用方法に関する有用な記事を翻訳しました。







私は以前、すでにJITに精通しているプログラマ向けにlibjitに関する紹介記事を公開しました。 少なくとも少し。 その投稿では、JITについてごく簡単に説明しましたが、この中でJITの完全なレビューを行い、追加のライブラリを必要としないコードでサンプルを補足します。







Jit定義



JITは、「Just In Time」の頭字語、またはロシア語に翻訳すると「オンザフライ」です。 これは何も伝えず、プログラミングとは関係がないかのように聞こえます。 私には、このJITの説明が真実である可能性が最も高いと思われます。







実行中のプログラムが、ディスク上の元のプログラムの一部ではない新しい実行可能コードを作成して実行する場合、これはJITです。

しかし、この名前はどこから来たのですか? 幸いなことに、カルガリー大学のジョン・イコックは、「JITの簡単な歴史」というタイトルの非常に興味深い記事を書いています。 この記事から判断すると、プログラム実行中のコード生成とコード実行に関する最初の言及は、McCarthyによって書かれたLISPに関する記事で1960年に登場しました。 後の作品(たとえば、 Thomsonの正規表現に関する1968年の記事 )では、このアプローチは非常に明白です(正規表現はマシンコードにコンパイルされ、その場で実行されます)。







JITという用語は、James GoslingのJavaの本に初めて登場しました。 ヘイコックは、ゴスリングが工業生産からこの用語を採用し、90年代初期にそれを使い始めたと言います。 詳細に興味がある場合は、Aikokの記事を読んでください。 次に、上記のすべてが実際にどのように機能するかを見てみましょう。







JIT:マシンコードを生成して実行する



JITをすぐに2つのフェーズに分割すると、JITの方が理解しやすいようです。









最初のフェーズは、JITの複雑さ全体の99%です。 しかし同時に、これはプロセスの最も平凡な部分です。これはまさに、通常のコンパイラが行うことです。 gccやclang / llvmなどの有名なコンパイラは、ソースをC / C ++からマシンコードに変換します。 さらに、マシンコードは通常ファイルに保存されますが、メモリに残さないことは意味がありません(実際、gccとclang / llvmの両方には、JITで使用するコードをメモリに保存するための既製のオプションがあります)。 しかし、この記事では、第2フェーズに焦点を当てたいと思います。







生成されたコードの実行



最近のオペレーティングシステムは、プログラムの動作中にプログラムが許可されていることに関して非常に選択的です。 ワイルドウェストの時代は、オペレーティングシステムがプロセスメモリのさまざまな部分にさまざまな権限を設定できる保護モードの出現で終わりました。 つまり、「通常」モードでは、ヒープにメモリを割り当てることができますが、最初にOSに明示的に問い合わせることなく、ヒープに割り当てられたコードを実行することはできません。







マシンコードは単なるデータであり、バイトの集合であると誰もが理解してくれることを願っています。 このように、例えば:







unsigned char[] code = {0x48, 0x89, 0xf8};
      
      





一部の場合、これらの3バイトは3バイトにすぎず、一部の場合、有効なx86-64コードのバイナリ表現です。







 mov %rdi, %rax
      
      





このマシンコードをメモリに入れるのはとても簡単です。 しかし、それを実行可能にし、実際に実行する方法は?







コードを見てみましょう



この記事の後半では、POSIX互換のUNIXオペレーティングシステム(つまりLinux)のコード例があります。 他のオペレーティングシステム(Windowsなど)では、コードの詳細は異なりますが、アプローチは異なります。 すべての最新のオペレーティングシステムには、同じことを行うのに便利なAPIがあります。







苦労せずに、メモリ内で関数を動的に作成して実行する方法を見てみましょう。 この機能は特別に非常にシンプルに作られています。 Cでは、次のようになります。







 long add4(long num) { return num + 4; }
      
      





ここに最初の試みがあります(Makefileと共に完全なソースコードがリポジトリで利用可能です):







 #include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/mman.h> //  RWX        .    //     NULL. void* alloc_executable_memory(size_t size) { void* ptr = mmap(0, size, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (ptr == (void*)-1) { perror("mmap"); return NULL; } return ptr; } void emit_code_into_memory(unsigned char* m) { unsigned char code[] = { 0x48, 0x89, 0xf8, // mov %rdi, %rax 0x48, 0x83, 0xc0, 0x04, // add $4, %rax 0xc3 // ret }; memcpy(m, code, sizeof(code)); } const size_t SIZE = 1024; typedef long (*JittedFunc)(long); //  RWX  . void run_from_rwx() { void* m = alloc_executable_memory(SIZE); emit_code_into_memory(m); JittedFunc func = m; int result = func(2); printf("result = %d\n", result); }
      
      





このコードが実行する3つの主な手順は次のとおりです。







  1. mmapを使用して、書き込み可能なヒープ、読み取り可能なメモリ、実行可能なメモリをヒープに割り当てます。
  2. add4を実装するマシンコードをこのメモリにコピーします。
  3. ポインターを関数へのポインターに変換し、このポインターを介して呼び出すことにより、このメモリからコードを実行します。


3番目の段階は、マシンコードを持つメモリの一部に実行権限がある場合にのみ可能であることに注意してください。 必要な権限がなければ、関数呼び出しはOSエラー(ほとんどの場合、セグメンテーションエラー)につながります。 たとえば、XでなくRWメモリを割り当てるmallocの通常の呼び出しでmを割り当てると、これが発生します。







しばらく気を散らす:ヒープ、malloc、およびmmap



熱心な読者は、私がmmapによって割り当てられたメモリを「ヒープからのメモリ」と呼んでいることに気付いたかもしれません。 厳密に言えば、「ヒープ」はmalloc



free



使用されるメモリソースの名前です。 コンパイラーによって直接制御されるスタックとは異なります。







しかし、それほど単純ではありません。 :-)伝統的に(つまり、非常に昔) malloc



が割り当てられたメモリ( sbrk



システムコール)に1つのソースのみを使用していた場合、多くの場合、ほとんどのmalloc



実装はmmap



使用します。 詳細は、実装ごとにOSによって異なりますが、通常、mmapは大きなメモリチャンクに使用され、 sbrk



は小さなメモリに使用されます。 オペレーティングシステムからメモリを取得する方法を使用する際のパフォーマンスの違い







したがって、私の意見では、mmapから受け取ったメモリを「ヒープからのメモリ」と呼ぶことはエラーではなく、この名前を使い続けるつもりです。







セキュリティを気にする



上記のコードには重大な脆弱性があります。 彼が割り当てたRWXメモリのブロックの理由は、悪用の楽園です。 もう少し責任を持ちましょう。 少し変更されたコードは次のとおりです。







 //  RW        .    //     NULL.    malloc,   //    ,        mprotect. void* alloc_writable_memory(size_t size) { void* ptr = mmap(0, size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (ptr == (void*)-1) { perror("mmap"); return NULL; } return ptr; } //  RX      .  // 0  .       -1. int make_memory_executable(void* m, size_t size) { if (mprotect(m, size, PROT_READ | PROT_EXEC) == -1) { perror("mprotect"); return -1; } return 0; } //  RW ,         RX  // . void emit_to_rw_run_from_rx() { void* m = alloc_writable_memory(SIZE); emit_code_into_memory(m); make_memory_executable(m, SIZE); JittedFunc func = m; int result = func(2); printf("result = %d\n", result); }
      
      





この例は、1つの点を除いて、すべての点で前の例と同等です。メモリには、最初にRW権利が割り当てられます(通常のmalloc



と同様)。 これらは十分な権利であるため、そこにコードを記述できます。 コードが既にメモリに格納されmprotect



mprotect



を使用してアクセス許可をRWからRXに変更し、書き込みを禁止します。 その結果、効果は同じですが、メモリの書き換えと実行が同時に行われる段階はありません。 これは、セキュリティの観点からは適切で正しいことです。







mallocはどうですか?



前のコードでmmap



代わりにmalloc



を使用してメモリを割り当てることはできますか? 結局、RWメモリは、 malloc



が提供するものです。 はい、できます。 しかし、アメニティよりも多くの問題があります。 実際、権利はページ全体にのみ設定できます。 また、 malloc



を使用してメモリを割り当てる間、メモリがページの境界に揃えられていることを手動で確認する必要があります。 Mmap



この問題を解決し、常にアライメントされたメモリを割り当てます( mmap



定義上、ページ全体でのみ機能するため)。







まとめると



この記事は、JITの一般的な概要(「JIT」と言うときの一般的な意味)で始まり、メモリからマシンコードを動的に実行する方法を示すコード例で終わりました。 この記事で紹介する手法は、実際のJITシステム(LLVMまたはlibjit)でJITを実行する方法に関するものです。 残っているのは、他の表現からマシンコードを生成する「単純な」部分だけです。







LLVMには本格的なコンパイラが含まれているため、CおよびC ++コードを(LLVM IR経由で)マシンコードに即座に変換して実行できます。 Libjitははるかに低いレベルで動作します。コンパイラのバックエンドとして機能します。 libjitに関する私の紹介記事では 、このライブラリを使用して重要なコードを生成および実行する方法を示します。 ただし、JITははるかに一般的な概念です。 データ構造正規表現、さらにはさまざまな言語の仮想マシンからCアクセスするためのコードをその場で作成できます。 私は私のブログのアーカイブを調べ、 8年前の記事でJITの言及を見つけました。 他のPerlコードをその場で(XML記述ファイルから)生成するPerlコードについてですが、考え方は同じです。







これが、JITを記述し、2つのフェーズを分離することが重要だと思う理由です。 第2フェーズ(この記事で説明しました)では、実装はかなり平凡であり、標準のオペレーティングシステムAPIを使用します。 可能性の最初のフェーズでは、無限の量。 そして、その中に正確に何が含まれるかは、最終的に開発している特定のアプリケーションに依存します。








All Articles