Dugačak posao bez blokiranja UI-a

↑ Nazad

Primer: generisanje N PDF dokumenata za štampu, sa progress barom, logom, pauzom i prekidom.

Problem

Obrada direktno u Click handleru zauzme UI nit. Prozor ne crta, ne reaguje na klik, Windows ga označi kao Not responding, a progress bar se ne pomera. Dugme „LOŠE: posao na UI niti (5 s)" u demo formi to pokazuje.

Pravilo

UI nit samo crta i reaguje na klik. Sve teško ide u Task.Run:

private async void btnStart_Click(object sender, EventArgs e)
{
    SetJobRunning(true);
    try
    {
        await Task.Run(() => _job.RunAsync(parallelism, _cts.Token));
    }
    catch (OperationCanceledException) { /* prekinuto */ }
    finally
    {
        SetJobRunning(false);   // posle await-a smo opet na UI niti → smemo da diramo kontrole
    }
}

Komunikacija u oba smera

          GUI → pozadina                          pozadina → GUI
 ┌──────────────────────────────┐     ┌───────────────────────────────────────┐
 │ Prekini → CancellationToken  │     │ BatchJob.Log  ─┐                      │
 │ Pauza   → ManualResetEvent   │     │ Shell.Output  ─┼→ ConcurrentQueue ─┐  │
 │ Enter   → shell stdin        │     │ Done/Failed   → Interlocked brojač │  │
 └──────────────────────────────┘     │                                    ▼  │
                                      │        uiTimer (100 ms) → TextBox,    │
                                      │                           ProgressBar │
                                      │ Shell.Exited → BeginInvoke (retko)    │
                                      └───────────────────────────────────────┘

Najvažnije za velike količine podataka

Ne radi Invoke za svaku liniju loga. 10.000 linija u sekundi znači 10.000 Invoke-a u sekundi, pa se UI zaguši isto kao da radi posao.

Umesto toga:

UI je zato podjednako brz bez obzira da li posao pravi 100 ili 100.000 linija u sekundi.

Šta BatchJob radi

BatchJob.cs

namespace WinFormsApp_test
{
    /// <summary>
    ///  Dugačak posao (simulacija: generisanje N PDF dokumenata za štampu).
    ///  Ne zna ništa o formi — nema TextBox-a, ProgressBar-a ni Invoke-a.
    ///  GUI → posao: CancellationToken (prekid), Pause()/Resume().
    ///  Posao → GUI: brojači Done/Failed (forma ih čita na tajmer) i Log event.
    /// </summary>
    public sealed class BatchJob
    {
        private int _done;
        private int _failed;
        private readonly ManualResetEventSlim _pauseGate = new(initialState: true);   // otvorena = radi

        public BatchJob(int total) => Total = total;

        public int Total { get; }
        public int Done => Volatile.Read(ref _done);
        public int Failed => Volatile.Read(ref _failed);
        public int Processed => Done + Failed;
        public bool IsPaused => !_pauseGate.IsSet;

        /// <summary>Jedna linija loga. Diže se sa POZADINSKIH niti — handler ne sme da dira kontrole.</summary>
        public event Action<string>? Log;

        public void Pause() => _pauseGate.Reset();
        public void Resume() => _pauseGate.Set();

        public async Task RunAsync(int parallelism, CancellationToken ct)
        {
            var options = new ParallelOptions { MaxDegreeOfParallelism = parallelism, CancellationToken = ct };

            await Parallel.ForEachAsync(Enumerable.Range(1, Total), options, async (id, token) =>
            {
                _pauseGate.Wait(token);   // na pauzi radnici stoje ovde; Cancel ih budi sa OperationCanceledException

                try
                {
                    int pages = await RenderDocumentAsync(id, token);
                    Interlocked.Increment(ref _done);
                    Log?.Invoke($"#{id:D6}  OK      {pages,3} str.");
                }
                catch (OperationCanceledException)
                {
                    throw;   // prekid nije greška dokumenta
                }
                catch (Exception ex)
                {
                    // Jedan loš zapis ne sme da obori ceo batch → zabeleži i nastavi
                    Interlocked.Increment(ref _failed);
                    Log?.Invoke($"#{id:D6}  GREŠKA  {ex.Message}");
                }
            });
        }

        /// <summary>
        ///  Ovde ide prava obrada: čitanje zapisa iz baze/CSV-a, popunjavanje šablona, render PDF-a, upis na disk.
        ///  Simulacija: malo CPU rada + malo čekanja na "disk", povremeno neispravan zapis.
        /// </summary>
        private static async Task<int> RenderDocumentAsync(int id, CancellationToken ct)
        {
            double x = 0;
            for (int i = 0; i < 300_000; i++) x += Math.Sqrt(i ^ id);   // "render"

            await Task.Delay(Random.Shared.Next(5, 30), ct);             // "upis na disk"

            if (Random.Shared.Next(300) == 0)
                throw new InvalidDataException("neispravan zapis u izvoru (prazno polje ADRESA)");

            return 1 + (int)(x % 12);
        }
    }
}

UiLogSink: bafer između niti i TextBox-a

UiLogSink.cs

using System.Collections.Concurrent;
using System.Text;

namespace WinFormsApp_test
{
    /// <summary>
    ///  Log bafer između pozadinskih niti i TextBox-a.
    ///  Write() sme da se zove sa BILO KOJE niti (samo stavlja u red, ne dira UI).
    ///  Flush() zove UI tajmer na ~100 ms i upisuje sve odjednom.
    ///  Zašto ne Invoke po liniji: 10.000 linija/s = 10.000 Invoke-a/s → UI se zaguši isto kao da radi posao.
    /// </summary>
    public sealed class UiLogSink
    {
        private readonly ConcurrentQueue<string> _queue = new();
        private readonly TextBox _target;
        private readonly int _maxChars;

        public UiLogSink(TextBox target, int maxChars = 1_000_000)
        {
            _target = target;
            _maxChars = maxChars;
        }

        public void Write(string line) => _queue.Enqueue(line);

        public void Info(string message) => Write($"{DateTime.Now:HH:mm:ss.fff}  {message}");

        /// <summary>Samo sa UI niti (npr. iz Timer.Tick).</summary>
        public void Flush()
        {
            if (_queue.IsEmpty) return;

            var sb = new StringBuilder();
            int lines = 0;
            while (lines < 5000 && _queue.TryDequeue(out var line))   // limit da jedan Tick ne traje predugo
            {
                sb.AppendLine(line);
                lines++;
            }

            // TextBox sa više MB teksta postaje spor → odseci stariju polovinu
            if (_target.TextLength + sb.Length > _maxChars)
                _target.Text = _target.Text[(_target.TextLength / 2)..];

            _target.AppendText(sb.ToString());   // AppendText i skroluje na kraj
        }

        public void Clear()
        {
            _queue.Clear();
            _target.Clear();
        }
    }
}

Provereno

Konzolni test bez forme: 2000 dokumenata obrađeno do kraja, pauza zaustavlja brojač (94 → 94 posle 1 s), nastavak ga pomera, a prekid tokom pauze uredno baca OperationCanceledException.

Izvor: 05-dugacak-posao.md