これは、タスクライブラリに関連する非常に難しい問題です。つまり、作成およびスケジュールされたタスクが多すぎるため、MongoDBのドライバーが待機しているタスクの1つを完了できなくなります。デッドロックのように見えますが、デッドロックではないことに気付くのに非常に長い時間がかかりました。
再現する手順は次のとおりです。
- MongoDBのCSharpドライバー のソースコードをダウンロードします 。
- そのソリューションを開き、内部にコンソールプロジェクトを作成し、ドライバープロジェクトを参照します。
- Main関数で、TestTaskを時間どおりに呼び出すSystem.Threading.Timerを作成します。タイマーを設定して、すぐに1回開始します。最後に、Console.Read()を追加します。
- TestTaskで、forループを使用して、Task.Factory.StartNew(DoOneThing)を呼び出して300個のタスクを作成します。これらすべてのタスクをリストに追加し、Task.WaitAllを使用して、すべてのタスクが完了するのを待ちます。
- DoOneThing関数で、MongoClientを作成し、簡単なクエリを実行します。
- 実行します。
これは、あなたが言及したのと同じ場所で失敗します:MongoDB.Driver.Core.Clusters.Cluster.WaitForDescriptionChangedHelper.HandleCompletedTask(Task completedTask)
いくつかのブレークポイントを設定すると、WaitForDescriptionChangedHelperがタイムアウトタスクを作成したことがわかります。次に、DescriptionUpdateタスクまたはタイムアウトタスクのいずれかが完了するのを待ちます。ただし、DescriptionUpdateは発生しませんが、なぜですか?
さて、私の例に戻ると、興味深い部分が1つあります。それは、タイマーを開始したことです。 TestTaskを直接呼び出すと、問題なく実行されます。それらをVisualStudioの[タスク]ウィンドウと比較すると、タイマーバージョンでは非タイマーバージョンよりもはるかに多くのタスクが作成されることがわかります。この部分については少し後で説明します。もう1つの重要な違いがあります。 Cluster.cs
にデバッグ行を追加する必要があります :
protected void UpdateClusterDescription(ClusterDescription newClusterDescription)
{
ClusterDescription oldClusterDescription = null;
TaskCompletionSource<bool> oldDescriptionChangedTaskCompletionSource = null;
Console.WriteLine($"Before UpdateClusterDescription {_descriptionChangedTaskCompletionSource?.Task.Id}, {_descriptionChangedTaskCompletionSource?.Task?.GetHashCode().ToString("F8")}");
lock (_descriptionLock)
{
oldClusterDescription = _description;
_description = newClusterDescription;
oldDescriptionChangedTaskCompletionSource = _descriptionChangedTaskCompletionSource;
_descriptionChangedTaskCompletionSource = new TaskCompletionSource<bool>();
}
OnDescriptionChanged(oldClusterDescription, newClusterDescription);
Console.WriteLine($"Setting UpdateClusterDescription {oldDescriptionChangedTaskCompletionSource?.Task.Id}, {oldDescriptionChangedTaskCompletionSource?.Task?.GetHashCode().ToString("F8")}");
oldDescriptionChangedTaskCompletionSource.TrySetResult(true);
Console.WriteLine($"Set UpdateClusterDescription {oldDescriptionChangedTaskCompletionSource?.Task.Id}, {oldDescriptionChangedTaskCompletionSource?.Task?.GetHashCode().ToString("F8")}");
}
private void WaitForDescriptionChanged(IServerSelector selector, ClusterDescription description, Task descriptionChangedTask, TimeSpan timeout, CancellationToken cancellationToken)
{
using (var helper = new WaitForDescriptionChangedHelper(this, selector, description, descriptionChangedTask, timeout, cancellationToken))
{
Console.WriteLine($"Waiting {descriptionChangedTask?.Id}, {descriptionChangedTask?.GetHashCode().ToString("F8")}");
var index = Task.WaitAny(helper.Tasks);
helper.HandleCompletedTask(helper.Tasks[index]);
}
}
これらの行を追加すると、タイマー以外のバージョンは2回更新されますが、タイマーバージョンは1回しか更新されないことがわかります。 2つ目は、ServerMonitor.csの「MonitorServerAsync」からのものです。タイマーバージョンでは、MontiorServerAsyncが実行されましたが、ServerMonitor.HeartbeatAsync、BinaryConnection.OpenAsync、BinaryConnection.OpenHelperAsync、およびTcpStreamFactory.CreateStreamAsyncを通過した後、最終的にTcpStreamFactory.ResolveEndPointsAsyncに到達しました。ここで悪いことが起こります:Dns.GetHostAddressesAsync
。これは決して実行されません。コードを少し変更して、次のように変換した場合:
var task = Dns.GetHostAddressesAsync(dnsInitial.Host).ConfigureAwait(false);
return (await task)
.Select(x => new IPEndPoint(x, dnsInitial.Port))
.OrderBy(x => x, new PreferredAddressFamilyComparer(preferred))
.ToArray();
タスクIDを見つけることができます。 Visual Studioの[タスク]ウィンドウを見ると、その前に約300のタスクがあることがはっきりとわかります。それらのいくつかだけが実行されていますが、ブロックされています。 DoOneThing関数にConsole.Writelineを追加すると、タスクスケジューラがそれらのいくつかをほぼ同時に開始することがわかりますが、その後、速度は1秒あたり約1に低下します。つまり、DNSを解決するタスクの実行が開始されるまで、約300秒待つ必要があります。そのため、30秒のタイムアウトを超えています。
さて、あなたがクレイジーなことをしていないなら、ここに簡単な解決策があります:
Task.Factory.StartNew(DoOneThing, TaskCreationOptions.LongRunning);
これにより、ThreadPoolSchedulerは、新しいスレッドを作成する前に1秒待つのではなく、すぐにスレッドを開始するように強制されます。
しかし、あなたが私のような本当にクレイジーなことをしているなら、これはうまくいきません。 forループを300から30000に変更してみましょう。このソリューションでも、失敗する可能性があります。その理由は、作成されるスレッドが多すぎるためです。これはリソースと時間がかかります。そして、GCプロセスを開始する可能性があります。全体として、時間がなくなる前にこれらすべてのスレッドの作成を完了できない場合があります。
完璧な方法は、多くのタスクの作成を停止し、デフォルトのスケジューラーを使用してそれらをスケジュールすることです。作業項目を作成してConcurrentQueueに入れてから、項目を消費するワーカーとして複数のスレッドを作成してみてください。
ただし、元の構造をあまり変更したくない場合は、次の方法を試すことができます。
TaskSchedulerから派生したThrottledTaskSchedulerを作成します。
- このThrottledTaskSchedulerは、実際のタスクを実行する基になるTaskSchedulerとして受け入れます。
- タスクを基になるスケジューラーにダンプしますが、制限を超える場合は、代わりにキューに入れます。
- タスクのいずれかが終了した場合は、キューを確認し、制限内で基になるスケジューラーにそれらをダンプしてみてください。
- 次のコードを使用して、これらのクレイジーな新しいタスクをすべて開始します。
・
var taskScheduler = new ThrottledTaskScheduler(
TaskScheduler.Default,
128,
TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler,
logger
);
var taskFactory = new TaskFactory(taskScheduler);
for (var i = 0; i < 30000; i++)
{
tasks.Add(taskFactory.StartNew(DoOneThing))
}
Task.WaitAll(tasks.ToArray());
System.Threading.Tasks.ConcurrentExclusiveSchedulerPair.ConcurrentExclusiveTaskSchedulerを参照として使用できます。必要なものよりも少し複雑です。それは他の目的のためです。したがって、ConcurrentExclusiveSchedulerPairクラス内の関数で行ったり来たりする部分について心配する必要はありません。ただし、ラッピングタスクの作成時にTaskCreationOptions.LongRunningを渡さないため、直接使用することはできません。
わたしにはできる。頑張ってください!
追伸:タイマーバージョンに多くのタスクがある理由は、おそらくTaskScheduler.TryExecuteTaskInline内にあるためです。 ThreadPoolが作成されたメインスレッドにある場合は、タスクをキューに入れずに実行できるようになります。