Wird von Task.Factory.StartNew () garantiert, dass ein anderer Thread als der aufrufende Thread verwendet wird?

75

Ich starte eine neue Aufgabe von einer Funktion aus, möchte aber nicht, dass sie auf demselben Thread ausgeführt wird. Es ist mir egal, auf welchem ​​Thread es läuft, solange es ein anderer ist (daher helfen die Informationen in dieser Frage nicht).

Ist mir garantiert, dass der folgende Code immer beendet wird, TestLockbevor Task ter erneut eingegeben werden kann? Wenn nicht, welches Entwurfsmuster wird empfohlen, um eine erneute Entität zu verhindern?

object TestLock = new object();

public void Test(bool stop = false) {
    Task t;
    lock (this.TestLock) {
        if (stop) return;
        t = Task.Factory.StartNew(() => { this.Test(stop: true); });
    }
    t.Wait();
}

Bearbeiten: Basierend auf der folgenden Antwort von Jon Skeet und Stephen Toub besteht eine einfache Möglichkeit, einen Wiedereintritt deterministisch zu verhindern, darin, ein CancellationToken zu übergeben, wie in dieser Erweiterungsmethode dargestellt:

public static Task StartNewOnDifferentThread(this TaskFactory taskFactory, Action action) 
 {
    return taskFactory.StartNew(action: action, cancellationToken: new CancellationToken());
}
Erwin Mayer
quelle
Ich bezweifle, dass Sie garantieren können, dass beim Aufruf ein neuer Thread erstellt wird StartNew. Eine Aufgabe ist als asynchrone Operation definiert, die nicht unbedingt einen neuen Thread impliziert. Möglicherweise wird auch irgendwo ein vorhandener Thread oder eine andere asynchrone Methode verwendet.
Tony The Lion
Wenn Sie mit C # 5, betrachtet Ersatz t.Wait()mit await t. Waitpasst nicht wirklich zur Philosophie von TPL.
CodesInChaos
2
Ja, das ist wahr, und es würde mir in diesem speziellen Fall nichts ausmachen. Aber ich bevorzuge ein deterministisches Verhalten.
Erwin Mayer
2
Wenn Sie einen Scheduler verwenden, der nur Aufgaben in einem bestimmten Thread ausführt, kann die Aufgabe nicht in einem anderen Thread ausgeführt werden. Es ist durchaus üblich, a zu verwenden SynchronizationContext, um sicherzustellen, dass Aufgaben auf dem UI-Thread ausgeführt werden. Wenn Sie den Code, der StartNewden UI-Thread aufgerufen hat , so ausführen würden, würden beide auf demselben Thread ausgeführt. Die einzige Garantie ist, dass die Aufgabe ab dem StartNewAufruf asynchron ausgeführt wird (zumindest wenn Sie kein RunSynchronouslyFlag
Peter Ritchie
7
Wenn Sie das Erstellen eines neuen Threads "erzwingen" möchten, verwenden Sie das Flag "TaskCreationOptions.LongRunning". Beispiel: Task.Factory.StartNew(() => { this.Test(stop: true); }, TaskCreationOptions.LongRunning); Was eine gute Idee ist, wenn Ihre Sperren den Thread für einen längeren Zeitraum in einen Wartezustand versetzen könnten.
Peter Ritchie

Antworten:

83

Ich habe Stephen Toub - ein Mitglied des PFX-Teams - über diese Frage informiert . Er ist sehr schnell und mit vielen Details zu mir zurückgekehrt - also kopiere ich einfach seinen Text und füge ihn hier ein. Ich habe nicht alles zitiert, da das Lesen einer großen Menge zitierten Textes weniger bequem ist als Vanille-Schwarz-auf-Weiß, aber das ist wirklich Stephen - ich weiß nicht so viel :) Ich habe es gemacht Dieses Antwort-Community-Wiki zeigt, dass all die Güte unten nicht wirklich mein Inhalt ist:

Wenn Sie Wait()eine abgeschlossene Aufgabe aufrufen , erfolgt keine Blockierung (es wird nur eine Ausnahme ausgelöstRanToCompletion , wenn die Aufgabe mit einem anderen TaskStatus als ausgeführt wurde oder auf andere Weise als Nein zurückgegeben wird ). Wenn Sie Wait()eine Task aufrufen , die bereits ausgeführt wird, muss sie blockiert werden, da es sonst nichts gibt, was sie vernünftigerweise tun kann (wenn ich Block sage, schließe ich sowohl echtes kernelbasiertes Warten als auch Drehen ein, da dies normalerweise eine Mischung aus beiden darstellt ). Wenn Sie Wait()eine Aufgabe mit dem Status Createdoder aufrufen WaitingForActivation, wird sie blockiert, bis die Aufgabe abgeschlossen ist. Keiner von diesen ist der interessante Fall, der diskutiert wird.

Der interessante Fall ist, wenn Sie Wait()eine Aufgabe im WaitingToRunStatus aufrufen , was bedeutet, dass sie zuvor in der Warteschlange eines TaskSchedulers stand , TaskScheduler jedoch noch nicht dazu gekommen ist, den Delegaten der Aufgabe tatsächlich auszuführen. In diesem Fall Waitfragt der Aufruf von den Scheduler, ob es in Ordnung ist, die Task dann und dort im aktuellen Thread über einen Aufruf der Scheduler- TryExecuteTaskInlineMethode auszuführen . Dies nennt man Inlining . Der Scheduler kann wählen, ob die Aufgabe entweder über einen Aufruf von inline geschaltet werden soll base.TryExecuteTask, oder er kann 'false' zurückgeben, um anzuzeigen, dass die Aufgabe nicht ausgeführt wird (häufig erfolgt dies mit Logik wie ...

return SomeSchedulerSpecificCondition() ? false : TryExecuteTask(task);

Der Grund, warum TryExecuteTaskein Boolescher Wert zurückgegeben wird, besteht darin, dass er die Synchronisierung übernimmt, um sicherzustellen, dass eine bestimmte Aufgabe immer nur einmal ausgeführt wird. Wenn ein Scheduler das Inlining der Aufgabe während Waitdieser Zeit vollständig verbieten möchte , kann es einfach so implementiert werden: return false; Wenn ein Scheduler das Inlining immer zulassen möchte, wann immer dies möglich ist, kann es einfach wie folgt implementiert werden:

return TryExecuteTask(task);

In der aktuellen Implementierung (sowohl .NET 4 als auch .NET 4.5, und ich persönlich erwarte keine Änderung) ermöglicht der Standard-Scheduler, der auf den ThreadPool abzielt, Inlining, wenn der aktuelle Thread ein ThreadPool-Thread ist und wenn dieser Thread der war eine, die zuvor die Aufgabe in die Warteschlange gestellt hat.

Beachten Sie, dass es hier keine willkürliche Wiedereintrittsmöglichkeit gibt, da der Standardplaner beim Warten auf eine Aufgabe keine willkürlichen Threads pumpt. Er erlaubt nur, dass diese Aufgabe inline ist, und natürlich entscheidet jede Inlining dieser Aufgabe wiederum machen. Beachten Sie Waitauch, dass der Scheduler unter bestimmten Bedingungen nicht einmal gefragt wird, sondern lieber blockiert wird. Wenn Sie beispielsweise ein stornierbares CancellationToken übergeben oder ein nicht unendliches Timeout übergeben, wird nicht versucht, eine Inline-Funktion auszuführen, da die Inline-Ausführung der Aufgabe beliebig lange dauern kann. Dies ist alles oder nichts Dies könnte die Stornierungsanforderung oder das Zeitlimit erheblich verzögern. Insgesamt versucht TPL hier ein angemessenes Gleichgewicht zwischen der Verschwendung des Threads zu finden, der das tutWaitdiesen Thread für zu viel verwenden und wiederverwenden. Diese Art von Inlining ist sehr wichtig für rekursive Divide-and-Conquer-Probleme (z. B. QuickSort ), bei denen Sie mehrere Aufgaben erzeugen und dann warten, bis alle abgeschlossen sind. Wenn dies ohne Inlining geschehen würde, würden Sie sehr schnell zum Stillstand kommen, wenn Sie alle Threads im Pool und alle zukünftigen Threads, die es Ihnen geben wollte, erschöpfen.

Unabhängig davon Waitist es auch (remote) möglich, dass der Task.Factory.StartNew- Aufruf die Task dann und dort ausführt, wenn der verwendete Scheduler die Task synchron als Teil des QueueTask-Aufrufs ausführt. Keiner der in .NET integrierten Scheduler wird dies jemals tun, und ich persönlich denke, es wäre ein schlechtes Design für Scheduler, aber es ist theoretisch möglich, z.

protected override void QueueTask(Task task, bool wasPreviouslyQueued)
{
    return TryExecuteTask(task);
}

Die Überlastung davon Task.Factory.StartNewakzeptiert nicht a TaskSchedulerverwendet den Scheduler von der TaskFactory, was im Fall von Task.FactoryZielen TaskScheduler.Current. Dies bedeutet, wenn Sie Task.Factory.StartNewaus einer Task heraus anrufen, die sich in der Warteschlange zu diesem Mythos befindet RunSynchronouslyTaskScheduler, wird auch eine Warteschlange angestellt RunSynchronouslyTaskScheduler, was dazu führt , dass der StartNewAufruf die Task synchron ausführt. Wenn Sie darüber überhaupt besorgt sind (z. B. wenn Sie eine Bibliothek implementieren und nicht wissen, von wo aus Sie aufgerufen werden), können Sie explizit TaskScheduler.Defaultan den StartNewAufruf übergeben, verwenden Task.Run(was immer geht TaskScheduler.Default), oder verwenden Sie ein TaskFactoryerstelltes Ziel TaskScheduler.Default.


EDIT: Okay, es sieht so aus, als hätte ich mich völlig geirrt und ein Thread, der gerade auf eine Aufgabe wartet, kann entführt werden. Hier ist ein einfacheres Beispiel dafür:

using System;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleApplication1 {
    class Program {
        static void Main() {
            for (int i = 0; i < 10; i++)
            {
                Task.Factory.StartNew(Launch).Wait();
            }
        }

        static void Launch()
        {
            Console.WriteLine("Launch thread: {0}", 
                              Thread.CurrentThread.ManagedThreadId);
            Task.Factory.StartNew(Nested).Wait();
        }

        static void Nested()
        {
            Console.WriteLine("Nested thread: {0}", 
                              Thread.CurrentThread.ManagedThreadId);
        }
    }
}

Beispielausgabe:

Launch thread: 3
Nested thread: 3
Launch thread: 3
Nested thread: 3
Launch thread: 3
Nested thread: 3
Launch thread: 3
Nested thread: 3
Launch thread: 4
Nested thread: 4
Launch thread: 4
Nested thread: 4
Launch thread: 4
Nested thread: 4
Launch thread: 4
Nested thread: 4
Launch thread: 4
Nested thread: 4
Launch thread: 4
Nested thread: 4

Wie Sie sehen, wird der wartende Thread häufig zur Ausführung der neuen Aufgabe wiederverwendet. Dies kann auch dann passieren, wenn der Thread eine Sperre erhalten hat. Böser Wiedereintritt. Ich bin entsprechend schockiert und besorgt :(

Jon Skeet
quelle
1
Im Fall eines einzelnen verfügbaren Threads würde sein Code blockieren, da "nachdem der Code, der diesen Thread verwendet, bereits damit fertig ist", niemals passiert.
CodesInChaos
1
Wenn Sie beispielsweise die Erstellung eines neuen Threads "erzwingen" (dh keinen Threadpool-Thread), wird kein Inlining angezeigt von Wait:Task.Factory.StartNew(Launch, TaskCreationOptions.LongRunning).Wait()
Peter Ritchie
1
@svick: Ich denke an Dinge wie anrufen, Waitwährend du ein Schloss besitzt. Sperren werden erneut eingegeben. Wenn also die Aufgabe, auf die Sie warten, versucht, die Sperre ebenfalls zu erwerben, ist sie erfolgreich - da sie die Sperre bereits besitzt, obwohl dies logischerweise in einer anderen Aufgabe der Fall ist. Das sollte zum Stillstand kommen, aber es wird nicht ... wenn es wieder eintritt. Grundsätzlich macht mich der Wiedereintritt im Allgemeinen nervös; es fühlt sich an, als würde es alle Arten von Annahmen verletzen.
Jon Skeet
1
Ich bedaure, dass ich F # nicht für dieses Projekt verwenden muss, um gezwungen zu sein, wiedereintrittsfreundlichen Code zu schreiben;)
Erwin Mayer
1
@ErwinMayer: Sie können ein stornierbares Token übergeben und es niemals abbrechen. Das sieht so aus, als würde es den Trick machen. Oder geben Sie eine Auszeit ein, die mehrere Jahre beträgt :)
Jon Skeet
4

Warum nicht einfach dafür entwerfen, anstatt sich nach hinten zu beugen, um sicherzustellen, dass es nicht passiert?

Die TPL ist hier ein roter Hering. Wiedereintritte können in jedem Code auftreten, vorausgesetzt, Sie können einen Zyklus erstellen, und Sie wissen nicht genau, was "südlich" Ihres Stapelrahmens passieren wird. Synchroner Wiedereintritt ist hier das beste Ergebnis - zumindest können Sie sich nicht (so leicht) selbst blockieren.

Sperren verwalten die Thread-übergreifende Synchronisierung. Sie sind orthogonal zum Management der Wiedereintrittsfähigkeit. Wenn Sie keine echte Ressource für den einmaligen Gebrauch (wahrscheinlich ein physisches Gerät, in diesem Fall sollten Sie wahrscheinlich eine Warteschlange verwenden) schützen, stellen Sie sicher, dass Ihr Instanzstatus konsistent ist, damit die Wiedereintrittsfunktion "einfach funktioniert".

(Nebengedanke: Sind Semaphoren ohne Dekrementierung wiedereintrittsfähig?)

piers7
quelle
0

Sie können dies leicht testen, indem Sie eine schnelle App schreiben, die eine Socket-Verbindung zwischen Threads / Aufgaben gemeinsam nutzt.

Die Aufgabe würde eine Sperre erhalten, bevor eine Nachricht über den Socket gesendet und auf eine Antwort gewartet wird. Sobald dies blockiert und inaktiv wird (IOBlock), setzen Sie eine andere Aufgabe im selben Block, um dasselbe zu tun. Es sollte beim Erwerb der Sperre blockiert werden. Wenn dies nicht der Fall ist und die zweite Task die Sperre übergeben darf, weil sie von demselben Thread ausgeführt wird, liegt ein Problem vor.

JonPen
quelle
0

Die new CancellationToken()von Erwin vorgeschlagene Lösung funktionierte bei mir nicht, Inlining trat trotzdem auf.

Also habe ich eine andere Bedingung verwendet, die von Jon und Stephen ( ... or if you pass in a non-infinite timeout ...) empfohlen wurde :

  Task<TResult> task = Task.Run(func);
  task.Wait(TimeSpan.FromHours(1)); // Whatever is enough for task to start
  return task.Result;

Hinweis: Wenn Sie hier der Einfachheit halber die Ausnahmebehandlung usw. weglassen, sollten Sie die im Produktionscode enthaltenen beachten.

Vitaliy Ulantikov
quelle