Wątki w JavaScript

0

Wielokrotnie implementowałem wielowątkowość w C++ i C#, teraz chciałbym w JavaScript w sposób podobny do C#. Za każdym razem, jak szukam coś o wątkach, to dostaję informację, żeby użyć Worker. Zrobiłem prosty i kompletny plik HTML zawierający skrypt.

Oczekuję rzeczy następującej:

  1. "Start infinity" - uruchamia pętlę w nieskończoność, pętla pracuje tak długo, aż kliknę "Stop".
  2. "Start 20 times" - uruchamia pętlę na 20 iteracji (trwa ok. 10 sekund) lub do kliknięcia "Stop" w zależności, co wcześniej nastąpi.
  3. "Stop" - sprawia, że warunek pętli przestaje być spełniony i aktualna iteracja pętli jest ostatnią.
  4. "Status" - wypisuje do konsoli przeglądarki stan, czy pętla pracuje i łączną liczbę iteracji od uruchomienia programu, powinien działać niezależnie od tego, czy pętla pracuje.

W rzeczywistości jest niestety tak:

  1. "Start infinity" - po jego kliknięciu żaden przycisk nie działa.
  2. "Start 20 times" - po jego kliknięciu żaden przycisk nie działa do czasu zakończenia pracy.
  3. "Stop" - nie działa, nic się nie dzieje po kliknięciu.
  4. "Status" - działa tylko w przypadku braku pracy, a w przypadku pracy, czeka na zakończenie pętli.

Co muszę zmienić, żeby wszystkie przyciski działały zgodnie z oczekiwaniem?

Wygląda na to, że zamiast wywoływać się od razu DemoObj.postMessage, one są kolejkowane i wywoływane tylko wtedy, gdy wątek nie pracuje. Pytanie sprowadza się do tego: Jak zmusić Worker, żeby wywołania DemoObj.postMessage wykonywał od razu?

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <style type="text/css">
        </style>
    </head>
    <body>
        <input type="button" value="Start infinity" onclick="DemoStart1()" />
        <input type="button" value="Start 20 times" onclick="DemoStart2()" />
        <input type="button" value="Stop" onclick="DemoStop()" />
        <input type="button" value="Status" onclick="DemoStatus()" />
        <script type="text/javascript">

// Skrypt workera jako funkcja, żeby można było załączyć w bieżącym pliku HTML
function DemoDef()
{
    let Working;
    let LoopCounter = 0;
    
    this.onmessage = function(Evt)
    {
        switch (Evt.data.Cmd)
        {
            case 0:
                {
                    Start(-1);
                }
                return;
            case 3:
                {
                    Start(20);
                }
                return;
            case 1:
                {
                    Stop();
                }
                return;
            case 2:
                {
                    GetStatus();
                }
                return;
        }
    }
    
    
    // Uruchomienie pętli, dodatni parametr przekazuje liczbę iteracji,
    // ujemy parametr znaczy wywołanie pętli nieskończonej
    function Start(C)
    {
        C = C + LoopCounter;
        Working = true;
        
        // Pętla pracuje dopóki Working=true, osobna funkcja
        // zmienia na Working=false, co powoduje zakończenie pętli
        while (Working)
        {
            // Symulacja obliczeń trwających pół sekundy
            let T = performance.now();
            T = T + 500;
            while (T > performance.now())
            {
            }

            // Zliczenie iteracji
            LoopCounter++;
            
            // Jezeli parametr jest dodatni, to należy opuścić pętlę po
            // takiej liczbie iteracji, ile wynosi parametr
            if (C > 0)
            {
                if (LoopCounter == C)
                {
                    Working = false;
                }
            }
        }
    }
    
    // Zatrzymanie pętli
    function Stop()
    {
        Working = false;
    }
    
    // Wysłanie statusu workera
    function GetStatus()
    {
        let StatusInfo = "Timestamp: " + performance.now();
        StatusInfo = StatusInfo + "\nWorking status: " + (Working ? "busy" : "idle");
        StatusInfo = StatusInfo + "\nLoop counter: " + LoopCounter;

        postMessage({ result: StatusInfo });
    }
}

// Konwersja skryptu workera na blob i tworzenie obiektu workera
let DemoObj = new Worker(URL.createObjectURL(new Blob(["("+DemoDef.toString()+")()"], {type: 'text/javascript'})));

// Wywolanie zwrotne workera
DemoObj.onmessage = function(Data)
{
    console.log(Data.data.result);
}

// Wywolanie zwrotne w przypadku błędu
DemoObj.onerror = function(Data)
{
    let ErrMsg = "Error: " + Data.message;
    ErrMsg = ErrMsg + "\n  line: " + Data.lineno;
    ErrMsg = ErrMsg + "\n  file: " + Data.filename;
    console.log(ErrMsg);
}

// Uruchomienie nieskończonej pętli
function DemoStart1()
{
    DemoObj.postMessage({Cmd:0});
}

// Uruchomienie 20 iteracji pętli (ok. 10 sekund)
function DemoStart2()
{
    DemoObj.postMessage({Cmd:3});
}

// Zatrzymanie pętli
function DemoStop()
{
    DemoObj.postMessage({Cmd:1});
}

// Zapytanie o status workera
function DemoStatus()
{
    DemoObj.postMessage({Cmd:2});
}

        </script>
    </body>
</html>
3

Zauważ, że wątek sam w sobie jest jednowątkowy - tzn. nie może on jednocześnie uruchamiać while (working) oraz onmessage, bo miałbyś data race.

Spróbuj zamienić Start na funkcję asynchroniczną w stylu:

function timeout(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

async function Start(C) {
  /* ... */

  while (/* ... */) {
    // zerowy timeout powinien pozwolić VMce na powrót do pętli zdarzeń i obsłużenie onmessage'y
    await timeout(0);
  }
}

Z ciekawości, w jaki sposób wyglądało Twoje rozwiązanie tego w C#?

1

@andrzejlisek: pytanie czy na prawdę chodzi Ci o wątki na poziomie OS, takie że lecą faktycznie na różnych core'ach, i musisz używać synchronizowanych bloków albo thread-safe kolekcji, czy chodzi Ci po prostu o asynchroniczne linijki takie jak np w Pythonie albo JS gdzie się przydają do nie czekania na blocking operations.

0

@Patryk27: W C# przedstawiony program wyglądałby mniej więcej tak (piszę w notatniku, bez kompilowania i uruchamiania). Dla przejrzystości kodu nie ma uruchamiania określonej liczby iteracji ani zabezpieczeń przed skutkami swobodnego dostępu do zmiennych przez dwa wątki (wątek interfejsu i wątek pętli), co wspominasz jako data race.

class ThrDemo
{
    bool Working = false;
    int LoopCounter = 0;

    void StartWork()
    {
        Working = true;
        while (Working)
        {
            Threading.Sleep(500);
            LoopCounter++;
        }
    }

    void Start()
    {
        Thread Thr = new Thread(StartWork);
        Thr.Start();
    }

    void Stop()
    {
        Working = false;
    }

    void GetStatus()
    {
        Console.WriteLine("Working status: " + (Working ? "busy" : "idle"));
        Console.WriteLine("Loop counter: " + LoopCounter);
    }
}

Jak chodzi o zabezpieczenia przed data race, to korzystam z Threading.Monitor, żeby zapewnić jednoczesny dostęp tylko jednego wątku do danego miejsca w kodzie. Na zabezpieczanie przyjdzie czas, jak w ogóle uda się uruchomić najprostszą wielowątkową implementację.

@Riddle: Nie wnikam w to, jak to będzie działać na poziomie OS lub przeglądarki (bo to też zależy od implementacji silnika JavaScript). Interesuje mnie efekt końcowy, nawet może być wszystko na jednym rdzeniu procesora, ale chodzi o to, żeby była zrealizowana taka idea:

  1. Jest zmienna globalna Working typu logicznego.
  2. Jest przycisk Start, który ustawia Working=true, a następnie uruchamia pętlę, która pracuje w tle. Pętla iteruje w nieskończoność dopóty, dopóki Working=true, jednak pętla nie blokuje GUI.
  3. Jest przycisk Stop, który ustawia Working=false i może to być wykonane w czasie pracy pętli. Jeżeli pętla pracuje, to wykona iterację do końca i zakończy się, gdyż przestanie być spełniony warunek pętli.
  4. Jest przycisk Status, który wyprowadza do interfejsu bieżącą wartość zmiennych Working i innych informacji przetwarzanych w pętli, np. licznik iteracji, bieżący stan obliczeń.

Jako przykład podam obliczanie sumy nieskończonego szeregu matematycznego (pomijam kwestię dokładności obliczeń zmiennoprzecinkowych). Klikam Start i program zaczyna obliczanie w tle, w kolejnych iteracjach pętli dodaje kolejne wyrazy. Przycisk Status wyprowadza na ekran bieżącą liczbę iteracji i bieżącą wartość sumy. Ta funkcja równie dobrze może być wywoływana cyklicznie za pomocą setTimeout, dzięki czemu obserwuję, jak szybko liczy i zliczył zadowalającą mnie liczbę wyrazów ciągu. Przycisk Stop kończy zliczanie, pętla przestaje pracować.

0

@Patryk27: Wykorzystując zaproponowany przez Ciebie kod udało mi się zrealizować to, co chciałem.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <style type="text/css">
        </style>
    </head>
    <body>
        <input type="button" value="Start infinity" onclick="DemoStart1()" />        <input type="button" value="Start 20 times" onclick="DemoStart2()" />
        <input type="button" value="Stop" onclick="DemoStop()" />
        <input type="button" value="Status" onclick="DemoStatus()" />
        <script type="text/javascript">

// Skrypt workera jako funkcja, żeby można było załączyć w bieżącym pliku HTML
function DemoDef()
{
    let Working;
    let LoopCounter = 0;
    let ExecTime = 0;
    
    this.onmessage = function(Evt)
    {
        switch (Evt.data.Cmd)
        {
            case 0:
                {
                    Start(-1);
                }
                return;
            case 3:
                {
                    Start(1000);
                }
                return;
            case 1:
                {
                    Stop();
                }
                return;
            case 2:
                {
                    GetStatus();
                }
                return;
        }
    }
    
    function timeout(ms)
    {
        return new Promise(resolve => setTimeout(resolve, ms));
    }    
    
    // Uruchomienie pętli, dodatni parametr przekazuje liczbę iteracji,
    // ujemy parametr znaczy wywołanie pętli nieskończonej
    async function Start(C)
    {
        C = C + LoopCounter;
        Working = true;
        ExecTime = performance.now();
        let EventTime = performance.now();
        
        // Pętla pracuje dopóki Working=true, osobna funkcja
        // zmienia na Working=false, co powoduje zakończenie pętli
        while (Working)
        {
            // Symulacja obliczeń trwających pół sekundy
            let T = performance.now();
            T = T + 10;
            while (T > performance.now())
            {
            }

            // Zliczenie iteracji
            LoopCounter++;
            
            // Jezeli parametr jest dodatni, to należy opuścić pętlę po
            // takiej liczbie iteracji, ile wynosi parametr
            if (C > 0)
            {
                if (LoopCounter == C)
                {
                    Working = false;
                }
            }
            
            if (EventTime < performance.now())
            {
                await timeout(0);
                EventTime = performance.now() + 100;
            }
            
        }
        
        ExecTime = performance.now() - ExecTime;
    }
    
    // Zatrzymanie pętli
    function Stop()
    {
        Working = false;
    }
    
    // Wysłanie statusu workera
    function GetStatus()
    {
        let StatusInfo = "Timestamp: " + performance.now();
        StatusInfo = StatusInfo + "\nWorking status: " + (Working ? "busy" : "idle");
        StatusInfo = StatusInfo + "\nLoop counter: " + LoopCounter;
        StatusInfo = StatusInfo + "\nLast exec time: " + ExecTime;
        

        postMessage({ result: StatusInfo });
    }
}

// Konwersja skryptu workera na blob i tworzenie obiektu workera
let DemoObj = new Worker(URL.createObjectURL(new Blob(["("+DemoDef.toString()+")()"], {type: 'text/javascript'})));

// Wywolanie zwrotne workera
DemoObj.onmessage = function(Data)
{
    console.log(Data.data.result);
}

// Wywolanie zwrotne w przypadku błędu
DemoObj.onerror = function(Data)
{
    let ErrMsg = "Error: " + Data.message;
    ErrMsg = ErrMsg + "\n  line: " + Data.lineno;
    ErrMsg = ErrMsg + "\n  file: " + Data.filename;
    console.log(ErrMsg);
}

// Uruchomienie nieskończonej pętli
function DemoStart1()
{
    DemoObj.postMessage({Cmd:0});
}

// Uruchomienie 20 iteracji pętli (ok. 10 sekund)
function DemoStart2()
{
    DemoObj.postMessage({Cmd:3});
}

// Zatrzymanie pętli
function DemoStop()
{
    DemoObj.postMessage({Cmd:1});
}

// Zapytanie o status workera
function DemoStatus()
{
    DemoObj.postMessage({Cmd:2});
}

        </script>
    </body>
</html>

Jak widać, do testów nieco przerobiłem, że jedno puste opóźnienie trwa 10ms, a wywołanie ograniczone to 1000 iteracji, czyli też 10 sekund. Wywołani samego await timeout(0) wydłuża pętlę 1000 iteracji do 13 sekund. Oczywiście można wywołać co którąś iterację lub za pomocą performance.now() pilnować, żeby uruchamiało się nie częściej niż co 100ms dobrane według potrzeb.

Czy ja to dobrze rozumiem, że w przypadku funkcji oczekujących w kolejce, wywołanie await timeout(0); wstrzyma działanie pętli, zostaną wywołane wszystkie zdarzenia i tym samym funkcja przypięta do this.onmessage, a potem pętla będzie kontynuowana? Rozumiem, ze tutaj nie ma niebezpieczeństwa data race?

Teraz jeszcze inne pytanie: W jaki sposób można przekazać obiekt z głównego wątku do Workera lub z powrotem? Nie chodzi o tworzenie kopii obiektu lub serializację/deserializację, tylko chodzi o to, żeby raz wątek główny, raz wątek z Workera pracowały na jednym i tym samym obiekcie. Chodzi o obiekt typu jakaś tablica, lista, JSON, tekst itp.

1

[...] ..a potem pętla będzie kontynuowana?

Tak; możesz o tym poczytać googlując o javascript event loop.

Rozumiem, ze tutaj nie ma niebezpieczeństwa data race?

Tak; nie ma tutaj niebezpieczeństwa.

W jaki sposób można przekazać obiekt z głównego wątku do Workera lub z powrotem?

Co do zasady dane możesz jedynie zserializować, wysyłać, a następnie przetworzone odebrać i zdeserializować (jest to tzw. message-passing concurrency, mocno promowany również w innych nowoczesnych językach - takich jak Go czy Rust - z racji nie tylko wygody, ale i bezpieczeństwa).

Jeśli jednak zależy Ci na wydajności, rzuć okiem na ArrayBuffer / SharedArrayBuffer - nie umożliwia to bezpośrednio pracowania na tym samym obiekcie, ale jest nie tak daleko.

1 użytkowników online, w tym zalogowanych: 0, gości: 1