web-dev-qa-db-ja.com

.NETで、個別の(単一の)スレッドでタスクのキューを管理する最良の方法

私は非同期プログラミングが長年にわたって多くの変化を見てきたことを知っています。わずか34歳でこのさびた髪を手に入れることができたのは少し恥ずかしいですが、StackOverflowを使ってスピードアップを期待しています。

私がやろうとしているのは、「作業」のキューを別のスレッドで管理することですが、一度に処理されるのは1つのアイテムだけです。このスレッドで作業を投稿したいのですが、呼び出し元に何も返す必要はありません。もちろん、新しいThreadオブジェクトを単にスピンアップし、スリープ、割り込み、待機ハンドルなどを使用して共有Queueオブジェクトをループさせることができます。 。 BlockingCollectionTaskasync/awaitがあり、その多くを抽象化するNuGetパッケージは言うまでもありません。

「何が最高か」という質問は一般的に眉をひそめていることを知っているので、組み込みの.NETメカニズムを使用してこのようなことを達成するために、「現在推奨されているものは何ですか」と言い替えます。しかし、サードパーティのNuGetパッケージが物事を単純化するのであれば、それも同じです。

最大同時実行数が1に固定されたTaskSchedulerインスタンスを検討しましたが、おそらく今までにそれを行うためのはるかに不格好な方法があるようです。

背景

具体的には、この場合にしようとしているのは、Webリクエスト中にIPジオロケーションタスクをキューに入れることです。同じIPがジオロケーションのキューに複数回入る可能性がありますが、タスクはそれを検出し、すでに解決されている場合は早期にスキップする方法を知っています。ただし、リクエストハンドラはこれらの() => LocateAddress(context.Request.UserHostAddress)はキューを呼び出し、LocateAddressメソッドに重複作業の検出を処理させます。私が使用しているジオロケーションAPIは、リクエストで攻撃されたくないため、一度に1つの同時タスクに制限したいのです。ただし、簡単なパラメーター変更でより多くの並行タスクに簡単にスケールできるアプローチが許可されていれば、素晴らしいことです。

23
Josh

非同期の単一並列作業キューを作成するには、SemaphoreSlimを作成し、1に初期化してから、要求された作業を開始する前にそのセマフォの取得時にエンキューメソッドawaitを使用します。 。

public class TaskQueue
{
    private SemaphoreSlim semaphore;
    public TaskQueue()
    {
        semaphore = new SemaphoreSlim(1);
    }

    public async Task<T> Enqueue<T>(Func<Task<T>> taskGenerator)
    {
        await semaphore.WaitAsync();
        try
        {
            return await taskGenerator();
        }
        finally
        {
            semaphore.Release();
        }
    }
    public async Task Enqueue(Func<Task> taskGenerator)
    {
        await semaphore.WaitAsync();
        try
        {
            await taskGenerator();
        }
        finally
        {
            semaphore.Release();
        }
    }
}

もちろん、1以外の一定の並列度を持たせるには、セマフォを他の数に初期化します。

43
Servy

私が見るようにあなたの最良のオプションは TPL DataflowActionBlock

var actionBlock = new ActionBlock<string>(address =>
{
    if (!IsDuplicate(address))
    {
        LocateAddress(address);
    }
});

actionBlock.Post(context.Request.UserHostAddress);

TPL Dataflowは堅牢で、スレッドセーフで、asyncに対応しており、アクターベースのフレームワークが非常に構成可能です(nugetとして利用可能)

以下は、より複雑なケースの簡単な例です。あなたがしたいと仮定しましょう:

  • 並行性を有効にします(使用可能なコアに制限されます)。
  • キューサイズを制限します(メモリが不足しないようにします)。
  • LocateAddressとキューの挿入の両方をasyncにします。
  • 1時間後にすべてをキャンセルします。
var actionBlock = new ActionBlock<string>(async address =>
{
    if (!IsDuplicate(address))
    {
        await LocateAddressAsync(address);
    }
}, new ExecutionDataflowBlockOptions
{
    BoundedCapacity = 10000,
    MaxDegreeOfParallelism = Environment.ProcessorCount,
    CancellationToken = new CancellationTokenSource(TimeSpan.FromHours(1)).Token
});

await actionBlock.SendAsync(context.Request.UserHostAddress);
16
i3arnon

実際には、1つのスレッドでタスクを実行する必要はなく、シリアルで(次々と)FIFOで実行する必要があります。 TPLにはそのためのクラスはありませんが、ここにテスト付きの非常に軽量でノンブロッキングの実装があります。 https://github.com/Gentlee/SerialQueue

また、@ Servyの実装もあります。テストでは、それが私のものより2倍遅く、FIFOを保証していません。

例:

private readonly SerialQueue queue = new SerialQueue();

async Task SomeAsyncMethod()
{
    var result = await queue.Enqueue(DoSomething);
}
14

つかいます BlockingCollection<Action> 1つのコンシューマ(必要に応じて一度に1つだけ実行される)と1つ以上のプロデューサでプロデューサ/コンシューマパターンを作成します。

最初にどこかに共有キューを定義します:

BlockingCollection<Action> queue = new BlockingCollection<Action>();

コンシューマーThreadまたはTaskから取得します。

//This will block until there's an item available
Action itemToRun = queue.Take()

次に、他のスレッド上の任意の数のプロデューサーから、単にキューに追加します。

queue.Add(() => LocateAddress(context.Request.UserHostAddress));
5
Zer0

ここに別のソリューションを投稿しています。正直に言うと、これが良い解決策かどうかはわかりません。

私は、BlockingCollectionを使用してプロデューサー/コンシューマーパターンを実装し、専用のスレッドがそれらのアイテムを消費するのに慣れています。常に入ってくるデータがあり、コンシューマスレッドがそこに座って何もしない場合は問題ありません。

アプリケーションの1つが別のスレッドでメールを送信したいというシナリオに遭遇しましたが、メールの総数はそれほど多くありません。私の最初の解決策は、専用のコンシューマスレッド(Task.Run()によって作成された)を使用することでしたが、多くの時間、そこに座って何もしません。

古いソリューション:

private readonly BlockingCollection<EmailData> _Emails =
    new BlockingCollection<EmailData>(new ConcurrentQueue<EmailData>());

// producer can add data here
public void Add(EmailData emailData)
{
    _Emails.Add(emailData);
}

public void Run()
{
    // create a consumer thread
    Task.Run(() => 
    {
        foreach (var emailData in _Emails.GetConsumingEnumerable())
        {
            SendEmail(emailData);
        }
    });
}

// sending email implementation
private void SendEmail(EmailData emailData)
{
    throw new NotImplementedException();
}

ご覧のとおり、送信する電子メールが十分にない場合(そして私の場合)、コンシューマスレッドはそれらのほとんどをそこに座って何もしません。

実装を次のように変更しました。

// create an empty task
private Task _SendEmailTask = Task.Run(() => {});

// caller will dispatch the email to here
// continuewith will use a thread pool thread (different to
// _SendEmailTask thread) to send this email
private void Add(EmailData emailData)
{
    _SendEmailTask = _SendEmailTask.ContinueWith((t) =>
    {
        SendEmail(emailData);
    });
}

// actual implementation
private void SendEmail(EmailData emailData)
{
    throw new NotImplementedException();
}

これはもはやプロデューサー/コンシューマーパターンではありませんが、スレッドが存在せず、何も行いません。代わりに、メールを送信するたびに、スレッドプールスレッドを使用して実行します。

5
Kael Xu