オブジェクトがまだ使用されている間にファイナライザーが起動されました
-
02-07-2019 - |
質問
まとめ: C#/.NET はガベージ コレクションされることになっています。C# には、リソースをクリーンアップするために使用されるデストラクターがあります。オブジェクト A が、その変数メンバーの 1 つをクローンしようとしているのと同じ行でガベージ コレクションされるとどうなりますか?どうやら、マルチプロセッサでは、ガベージ コレクターが勝つ場合があるようです...
問題
今日、C# のトレーニング セッションで、先生がマルチプロセッサで実行する場合にのみバグを含むコードをいくつか見せてくれました。
要約すると、呼び出されたメソッドから戻る前に C# クラス オブジェクトのファイナライザーを呼び出すことにより、コンパイラまたは JIT が失敗することがあります。
Visual C++ 2005 ドキュメントに記載されている完全なコードは、非常に大きな質問を避けるために「回答」として掲載されますが、重要なものは以下のとおりです。
次のクラスには、内部配列の複製コピーを返す「Hash」プロパティがあります。この構築では、配列の最初の項目の値は 2 になります。デストラクターでは、その値はゼロに設定されます。
ポイントは:「Example」の「Hash」プロパティを取得しようとすると、オブジェクトが使用されているため (したがって、ガベージ コレクション/ファイナライズされていない)、配列のクリーン コピーが取得されます。その最初の項目は 2 のままです。 ):
public class Example
{
private int nValue;
public int N { get { return nValue; } }
// The Hash property is slower because it clones an array. When
// KeepAlive is not used, the finalizer sometimes runs before
// the Hash property value is read.
private byte[] hashValue;
public byte[] Hash { get { return (byte[])hashValue.Clone(); } }
public Example()
{
nValue = 2;
hashValue = new byte[20];
hashValue[0] = 2;
}
~Example()
{
nValue = 0;
if (hashValue != null)
{
Array.Clear(hashValue, 0, hashValue.Length);
}
}
}
しかし、それほど単純なものはありません...このクラスを使用するコードはスレッド内で動作しており、もちろんテストでは、アプリは高度にマルチスレッド化されています。
public static void Main(string[] args)
{
Thread t = new Thread(new ThreadStart(ThreadProc));
t.Start();
t.Join();
}
private static void ThreadProc()
{
// running is a boolean which is always true until
// the user press ENTER
while (running) DoWork();
}
DoWork 静的メソッドは、問題が発生するコードです。
private static void DoWork()
{
Example ex = new Example();
byte[] res = ex.Hash; // [1]
// If the finalizer runs before the call to the Hash
// property completes, the hashValue array might be
// cleared before the property value is read. The
// following test detects that.
if (res[0] != 2)
{
// Oops... The finalizer of ex was launched before
// the Hash method/property completed
}
}
DoWork が 1,000,000 回実行されるごとに、ガベージ コレクターが魔法を実行し、関数の残りのコードで参照されなくなった「ex」を再利用しようとします。今回は「ハッシュ」よりも高速です。メソッドを取得します。したがって、最終的に得られるのは、正しいもの (最初の項目が 2 である) ではなく、ゼロで始まるバイト配列のクローンです。
私の推測では、コードのインライン化があり、基本的に DoWork 関数内の [1] とマークされた行を次のような行に置き換えます。
// Supposed inlined processing
byte[] res2 = ex.Hash2;
// note that after this line, "ex" could be garbage collected,
// but not res2
byte[] res = (byte[])res2.Clone();
Hash2 が次のようにコーディングされた単純なアクセサーであると仮定すると、
// Hash2 code:
public byte[] Hash2 { get { return (byte[])hashValue; } }
そこで、質問は次のとおりです。 これは C#/.NET でそのように動作することになっているのでしょうか、それとも JIT のコンパイラのバグと考えられますか?
編集
説明については、Chris Brumme と Chris Lyons のブログを参照してください。
http://blogs.msdn.com/cbrumme/archive/2003/04/19/51365.aspx
http://blogs.msdn.com/clyon/archive/2004/09/21/232445.aspx
全員の回答は興味深いものでしたが、どちらか一方を選ぶことはできませんでした。なので皆さんに+1を付けさせていただきました...
ごめん
:-)
編集2
同じ条件(複数の同じ実行可能ファイルの同時実行、リリースモードなど)で同じコードを使用したにもかかわらず、Linux/Ubuntu/Mono では問題を再現できませんでした。
解決
それは単にコードのバグです。ファイナライザーは管理オブジェクトにアクセスすべきではありません。
ファイナライザーを実装する唯一の理由は、アンマネージ リソースを解放することです。この場合、慎重に実装する必要があります 標準的な IDisposable パターン.
このパターンでは、保護されたメソッド「protected Dispose(bool disposing)」を実装します。このメソッドがファイナライザーから呼び出されると、アンマネージド リソースはクリーンアップされますが、マネージド リソースはクリーンアップされません。
あなたの例では、アンマネージド リソースがないため、ファイナライザーを実装すべきではありません。
他のヒント
あなたが見ているものは完全に自然なものです。
バイト配列を所有するオブジェクトへの参照は保持しないため、そのオブジェクト (バイト配列ではない) は実際にはガベージ コレクターが自由に収集できます。
ガベージ コレクターは本当に攻撃的になる可能性があります。
したがって、内部データ構造への参照を返すオブジェクトのメソッドを呼び出し、オブジェクトのファイナライザーがそのデータ構造を台無しにする場合は、オブジェクトへのライブ参照も保持する必要があります。
ガベージ コレクターは、 ex 変数がそのメソッドでもう使用されていないことを認識するため、適切な状況 (つまり、タイミングと必要性)。
これを行う正しい方法は、ex で GC.KeepAlive を呼び出すことです。そのため、次のコード行をメソッドの最後に追加すると、すべてがうまくいくはずです。
GC.KeepAlive(ex);
私は本を読んでこの攻撃的な行動について知りました .NET Framework プログラミングの応用 ジェフリー・リヒター著。
これは、作業スレッドと GC スレッドの間で競合状態が発生しているように見えます。それを避けるには、次の 2 つの選択肢があると思います。
(1) ex が途中で GC されないように、res の代わりに ex.Hash[0] を使用するように if ステートメントを変更します。または
(2) Hash への呼び出しの間、ex をロックします。
これはかなり気の利いた例です。教師の指摘は、JIT コンパイラにマルチコア システムでのみ現れるバグがある可能性がある、またはこの種のコーディングではガベージ コレクションとの微妙な競合状態が発生する可能性があるということでしたでしょうか。
あなたが見ているものは次のとおりだと思います 合理的 複数のスレッドで実行されているという事実による動作。これが GC.KeepAlive() メソッドの理由であり、この場合、オブジェクトがまだ使用されており、クリーンアップの候補ではないことを GC に伝えるためにこのメソッドを使用する必要があります。
「完全なコード」応答の DoWork 関数を見ると、次のコード行の直後に問題があります。
byte[] res = ex.Hash;
関数はもう参照しません。 元 オブジェクトなので、その時点でガベージ コレクションの対象になります。GC.KeepAlive への呼び出しを追加すると、これが発生するのを防ぐことができます。
Chris Brumme のブログからの興味深いコメント
http://blogs.msdn.com/cbrumme/archive/2003/04/19/51365.aspx
class C {<br>
IntPtr _handle;
Static void OperateOnHandle(IntPtr h) { ... }
void m() {
OperateOnHandle(_handle);
...
}
...
}
class Other {
void work() {
if (something) {
C aC = new C();
aC.m();
... // most guess here
} else {
...
}
}
}
したがって、上記のコード内で「aC」がどのくらいの期間存続するかはわかりません。JIT は、Other.work() が完了するまで参照を報告する可能性があります。Other.work() を他のメソッドにインライン化し、aC をさらに長く報告する可能性があります。「ac = null;」を追加してもそれを使用した後、JITはこの割り当てが死んだコードであると自由に検討し、それを排除することができます。JIT が参照の報告をいつ停止するかに関係なく、GC はしばらく参照を収集できない可能性があります。
aC が収集される最も早い時点を心配することの方が興味深いです。あなたがほとんどの人と同じなら、aC が収集の対象となるのは、Other.work() の「if」句の閉じ括弧の時点であると推測するでしょう。ここにコメントを追加しました。実際、中括弧は IL には存在しません。これらは、ユーザーと言語コンパイラーの間の構文上の契約です。 Other.work() は、aC.m() への呼び出しを開始するとすぐに、aC の報告を自由に停止できます。
これは、ex.hashコールの後、clrは元インスタンスがもう必要ないことを知っているように、あなたのdo作業方法で最終化者が呼び出されるのは完全に非norです...
ここで、インスタンスを存続させたい場合は、次のようにします。
private static void DoWork()
{
Example ex = new Example();
byte[] res = ex.Hash; // [1]
// If the finalizer runs before the call to the Hash
// property completes, the hashValue array might be
// cleared before the property value is read. The
// following test detects that.
if (res[0] != 2) // NOTE
{
// Oops... The finalizer of ex was launched before
// the Hash method/property completed
}
GC.KeepAlive(ex); // keep our instance alive in case we need it.. uh.. we don't
}
GC.KeepAlive は...何もありません:) これは空でインライン化不可能な /jittable メソッドで、その唯一の目的は GC をだましてオブジェクトがこの後に使用されると思わせることです。
警告:DoWork メソッドがマネージド C++ メソッドであった場合、あなたの例は完全に有効です...あなた する 別のスレッド内からデストラクターを呼び出したくない場合は、手動でマネージド インスタンスを有効にしておく必要があります。いえ。ファイナライズ時にアンマネージ メモリの BLOB を削除するマネージド オブジェクトへの参照を渡し、メソッドはこれと同じ BLOB を使用します。インスタンスを存続させないと、GC とメソッドのスレッドの間で競合状態が発生します。
そしてこれは最終的には涙を流すことになります。そしてマネージドヒープの破損...
完全なコード
以下に、Visual C++ 2008 .cs ファイルからコピー/ペーストした完全なコードを示します。私は現在 Linux を使用していますが、Mono コンパイラーやその使用法に関する知識がないので、テストを行う方法はありません。それでも、数時間前に、次のコードが機能することとそのバグを確認しました。
using System;
using System.Threading;
public class Example
{
private int nValue;
public int N { get { return nValue; } }
// The Hash property is slower because it clones an array. When
// KeepAlive is not used, the finalizer sometimes runs before
// the Hash property value is read.
private byte[] hashValue;
public byte[] Hash { get { return (byte[])hashValue.Clone(); } }
public byte[] Hash2 { get { return (byte[])hashValue; } }
public int returnNothing() { return 25; }
public Example()
{
nValue = 2;
hashValue = new byte[20];
hashValue[0] = 2;
}
~Example()
{
nValue = 0;
if (hashValue != null)
{
Array.Clear(hashValue, 0, hashValue.Length);
}
}
}
public class Test
{
private static int totalCount = 0;
private static int finalizerFirstCount = 0;
// This variable controls the thread that runs the demo.
private static bool running = true;
// In order to demonstrate the finalizer running first, the
// DoWork method must create an Example object and invoke its
// Hash property. If there are no other calls to members of
// the Example object in DoWork, garbage collection reclaims
// the Example object aggressively. Sometimes this means that
// the finalizer runs before the call to the Hash property
// completes.
private static void DoWork()
{
totalCount++;
// Create an Example object and save the value of the
// Hash property. There are no more calls to members of
// the object in the DoWork method, so it is available
// for aggressive garbage collection.
Example ex = new Example();
// Normal processing
byte[] res = ex.Hash;
// Supposed inlined processing
//byte[] res2 = ex.Hash2;
//byte[] res = (byte[])res2.Clone();
// successful try to keep reference alive
//ex.returnNothing();
// Failed try to keep reference alive
//ex = null;
// If the finalizer runs before the call to the Hash
// property completes, the hashValue array might be
// cleared before the property value is read. The
// following test detects that.
if (res[0] != 2)
{
finalizerFirstCount++;
Console.WriteLine("The finalizer ran first at {0} iterations.", totalCount);
}
//GC.KeepAlive(ex);
}
public static void Main(string[] args)
{
Console.WriteLine("Test:");
// Create a thread to run the test.
Thread t = new Thread(new ThreadStart(ThreadProc));
t.Start();
// The thread runs until Enter is pressed.
Console.WriteLine("Press Enter to stop the program.");
Console.ReadLine();
running = false;
// Wait for the thread to end.
t.Join();
Console.WriteLine("{0} iterations total; the finalizer ran first {1} times.", totalCount, finalizerFirstCount);
}
private static void ThreadProc()
{
while (running) DoWork();
}
}
興味のある方は、zip 形式のプロジェクトを電子メールで送信できます。