web-dev-qa-db-fra.com

Meilleure façon, en .NET, de gérer la file d’attente de tâches sur un thread séparé (unique)

Je sais que la programmation asynchrone a connu de nombreux changements au fil des ans. Je suis un peu gêné de ne pas me laisser avoir cette rouille à tout juste 34 ans, mais je compte sur StackOverflow pour me mettre à niveau.

Ce que j'essaie de faire, c'est gérer une file d'attente de "travail" sur un thread séparé, mais de manière à ce qu'un seul élément soit traité à la fois. Je veux poster du travail sur ce fil et il n'a pas besoin de renvoyer quoi que ce soit à l'appelant. Bien sûr, je pouvais simplement créer un nouvel objet Thread et le faire passer en boucle sur un objet Queue partagé, en utilisant des dormations, des interruptions, des poignées d'attente, etc. Nous avons BlockingCollection, Task, async/await, sans parler des paquets NuGet qui en abstraient probablement beaucoup.

Je sais que les questions "Quel est le meilleur ..." sont généralement désapprouvées; je vais donc reformuler ma question en disant "Quel est actuellement la recommandation ..." pour réaliser de telles choses en utilisant de préférence les mécanismes .NET intégrés. Mais si un package NuGet tiers simplifie énormément les choses, c'est tout aussi bien.

J'ai considéré une instance TaskScheduler avec une simultanéité maximale fixe de 1, mais il semble qu'il existe probablement une manière beaucoup moins lourde de le faire maintenant.

Contexte

Plus précisément, ce que j'essaie de faire dans ce cas est de mettre en file d'attente une tâche de géolocalisation IP lors d'une requête Web. La même adresse IP peut parfois être mise en file d'attente pour la géolocalisation, mais la tâche saura comment la détecter et la quitter plus tôt si elle est déjà résolue. Mais le gestionnaire de demandes va simplement lancer ces appels () => LocateAddress(context.Request.UserHostAddress) dans une file d'attente et laisser la méthode LocateAddress gérer la détection du travail en double. L'API de géolocalisation que j'utilise n'aime pas être bombardée de demandes, c'est pourquoi je souhaite la limiter à une seule tâche simultanée à la fois. Cependant, ce serait bien si l’on permettait à l’approche de s’adapter facilement à davantage de tâches simultanées avec un simple changement de paramètre.

18
Josh

Pour créer une file de travail à un seul degré de parallélisme asynchrone, vous pouvez simplement créer une SemaphoreSlim, initialisée à une, puis faire en sorte que la méthode enqueing await sur l'acquisition de ce sémaphore avant de commencer le travail demandé.

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();
        }
    }
}

Bien sûr, pour avoir un degré de parallélisme fixe autre que celui initialisé, initialisez simplement le sémaphore à un autre nombre.

40
Servy

Votre meilleure option, à mon avis, consiste à utiliser TPL Dataflow 's ActionBlock:

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

actionBlock.Post(context.Request.UserHostAddress);

TPL Dataflow est robuste, thread-safe, async- ready et très configurable, basé sur un acteur (disponible sous forme de nuget)

Voici un exemple simple pour un cas plus compliqué. Supposons que vous vouliez:

  • Activer la simultanéité (limitée aux cœurs disponibles).
  • Limitez la taille de la file d'attente (pour ne pas manquer de mémoire).
  • Avez LocateAddress et l’insertion de la file d’attente être async.
  • Tout annuler après une heure.

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);
15
i3arnon

En fait, vous n’avez pas besoin d’exécuter des tâches dans un seul thread, vous devez les exécuter en série (une après l’autre) et dans FIFO. TPL n'a pas de classe pour cela, mais voici ma mise en œuvre très légère et non bloquante avec des tests. https://github.com/Gentlee/SerialQueue

La mise en œuvre de @Servy y est également présente; les tests montrent qu’elle est deux fois plus lente que la mienne et qu’elle ne garantit pas la FIFO.

Exemple:

private readonly SerialQueue queue = new SerialQueue();

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

Utilisez BlockingCollection<Action> pour créer un modèle producteur/consommateur avec un consommateur (un seul élément à la fois comme vous le souhaitez) et un ou plusieurs producteurs.

Commencez par définir une file d'attente partagée quelque part:

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

Thread ou Task dans votre consommation, vous en tirez:

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

Puis de n'importe quel nombre de producteurs sur d'autres threads, ajoutez simplement à la file d'attente:

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

Je poste une solution différente ici. Pour être honnête, je ne suis pas sûr que ce soit une bonne solution.

J'ai l'habitude d'utiliser BlockingCollection pour implémenter un modèle producteur/consommateur, avec un thread dédié consommant ces éléments. C'est bien s'il y a toujours des données qui arrivent et qu'un fil consommateur ne restera pas là sans rien faire.

J'ai rencontré un scénario dans lequel l'un des utilisateurs souhaiterait envoyer des e-mails sur un autre thread, mais le nombre total d'e-mails n'est pas si énorme . Ma solution initiale consistait à disposer d'un thread dédié (créé par Task.Run () ), mais beaucoup de temps il reste là et ne fait rien.

Ancienne solution:

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();
}

Comme vous pouvez le constater, s’il n’ya pas assez d’e-mails à envoyer (et c’est mon cas), le fil de discussion grand public passera la plupart du temps assis sans rien faire.

J'ai changé ma mise en œuvre pour:

// 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();
}

Ce n'est plus un modèle producteur/consommateur, mais il n'y aura pas de fil conducteur et ne fera rien. Chaque fois qu'il enverra un courrier électronique, il utilisera un thread de pool de threads pour le faire.

2
Kael Xu