---
title: "Многопоточность в GUI"
description: "Потоки в Fyne: главный цикл событий, запрет обновления UI из горутин, мосты fyne.Do и fyne.DoAndWait, прогресс из фоновой задачи, гонки и -race."
course: "Fyne: десктопные и мобильные GUI на Go"
url: "https://golangmaster.ru/courses/fyne/lessons/20-gorutiny-gui/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Многопоточность в GUI

*Курс «Fyne: десктопные и мобильные GUI на Go», урок 20 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/fyne/lessons/20-gorutiny-gui/*

Шесть уроков назад курс впервые произнёс правило: интерфейс трогают только из главного потока, длинную работу уноси в горутину, а возвращайся через fyne.Do. Пора отдать долг полностью. Запрос «fyne goroutines» — это жизнь приложения после ShowAndRun: главный goroutine крутит цикл событий, все колбэки с версии 2.6.0 приходят именно в него, и каждая попытка обновить виджет из твоей собственной горутины — лотерея с гонкой данных в качестве приза. Русский запрос «fyne горутины» приводит к тому же разбору: семантика fyne.Do и fyne.DoAndWait, очередь обновлений, цена ожидания, мост через привязки из семнадцатого урока и набор рецептов — таймер, сетевой запрос, отмена по контексту. Урок собирает разрозненные оговорки прошлых глав в одну карту потоков: где можно, где нельзя, что бывает за нарушение и почему платит за него пользователь, а не компилятор.

### Один поток, один интерфейс

У приложения на Fyne есть поток-владелец: главный goroutine, который после ShowAndRun из каркаса [второго урока](/courses/fyne/lessons/02-pervoe-okno/) перестаёт быть твоим и становится циклом событий. Он выбирает уведомления операционной системы, определяет, над каким объектом курсор или палец, вызывает твои колбэки и раз в кадр отрисовывает то, что помечено изменённым. С версии 2.6.0 договорённость ужесточилась: все события и колбэки тулкита исполняются в одном и том же goroutine — и обработчик кнопки, и слушатель настроек, и колбэк диалога из тринадцатого урока. Это даёт скорость и предсказуемость: внутри обработчика ты можешь спокойно читать и менять любые виджеты, гонок с самим собой не будет. Обратная сторона — твоя горутина, запущенная через go func(), живёт вне этого мира: для неё виджеты — чужая память без защиты. Русский запрос «fyne goroutines» — это вопрос о границе между этими двумя мирами и о мосте через неё. Граница простая: всё, что имеет отношение к экрану — SetText, Refresh, SetContent, Show, диалоги, заголовок окна, — живёт в главном потоке; всё, что умеет без экрана — сеть, файлы, расчёты, ожидания, — живёт где угодно. Мост — две функции из пакета fyne, о которых дальше.

### Часы из горутины

```go
package main

import (
	"time"

	"fyne.io/fyne/v2"
	"fyne.io/fyne/v2/app"
	"fyne.io/fyne/v2/widget"
)

func main() {
	myApp := app.NewWithID("io.gomaster.clock")
	window := myApp.NewWindow("Часы")

	output := widget.NewLabel("--:--:--")
	output.TextStyle.Monospace = true

	go func() {
		ticker := time.NewTicker(time.Second)
		for now := range ticker.C {
			stamp := now.Format("15:04:05")
			fyne.Do(func() {
				output.SetText(stamp)
			})
		}
	}()

	window.SetContent(output)
	window.ShowAndRun()
}
```

Горутина с тикером живёт своей жизнью и раз в секунду готовит строку. Само обновление спрятано в fyne.Do: анонимная функция уезжает в очередь главного потока и исполняется там, где SetText безопасен. Это официальный паттерн обновления из фона — канонический пример часов из документации Fyne.

**Вопрос:** Что произойдёт, если убрать fyne.Do и вызвать output.SetText(stamp) прямо в горутине с тикером?

- Fyne молча перенаправит вызов в главный поток — разницы не будет
- Ничего страшного: SetText потокобезопасен в любой версии Fyne
- ✅ Это гонка данных: может повезти, а может испортиться состояние виджета — вплоть до падения приложения
- Программа гарантированно упадёт с паникой при первом же тике

Состояние виджета не защищено от твоих горутин, а отрисовка идёт параллельно: исходным становится спор «кто прочтёт поле посреди чужой записи». Исход непредсказуем — от удачных запусков до порчи данных и падений. Свежие версии Fyne умеют замечать часть таких вызовов и предупреждать, но полагаться на это нельзя: правило главное, а предупреждение — подстраховка.

### Почему правило существует: гонка данных

Гонка данных — это когда два потока обращаются к одной ячейке памяти одновременно, и хотя бы один пишет. Для Label это выглядит невинно: горутина пишет в поле Text, а главный поток в тот же момент рисует виджет и читает то же поле. Что прочитает отрисовка — старую строку, новую или половину каждой — решает планировщик, и от запуска к запуску исход разный. Хуже того: запись строки не атомарна, и читатель может увидеть разорванное значение, а составные структуры — невалидную память. Для map и слайсов те же гонки кончаются фатальнее: рантайм Go умеет ловить одновременную запись в карту и убивает процесс. Дешёвого «поставить мьютекс на каждый виджет» в тулките нет намеренно: каждый вызов с локом раз за кадр на каждый объект из дерева — это умножение стоимости отрисовки на порядок, и ради этого Fyne выбрал контракт «один поток — один интерфейс» вместо повсеместных замков. Твоя страховка — детектор гонок: собери приложение через go run -race и гоняй сценарий с фоновыми задачами; детектор печатает стеки обоих участников спора. Он ловит только фактические совпадения по времени, поэтому сценарий стоит прогнать несколько раз и под нагрузкой — тишина одного прогона ничего не доказывает.

### Порядок не гарантирован

```go
go func() {
	fyne.Do(func() { output.SetText("готово") })
	fmt.Println("поставил в очередь")
}()
```

**Что выведет программа?**

- «готово» гарантированно появится в метке раньше, чем напечатается строка
- Строка гарантированно напечатается раньше обновления метки
- ✅ Порядок не гарантирован: fyne.Do ставит функцию в очередь и сразу возвращается
- Программа заблокируется, пока функция из fyne.Do не исполнится

fyne.Do асинхронен: он положил твою функцию в очередь главного потока и вернулся, а горутина пошла дальше — к печати. Успеет ли главный поток дойти до очереди до печати, зависит от того, чем он занят прямо сейчас. Когда порядок важен, есть fyne.DoAndWait — о нём ниже.

### fyne.Do: мост обратно в главный поток

Обе функции-моста появились в версии 2.6.0 и живут в самом пакете fyne. fyne.Do(fn) ставит функцию fn в очередь главного потока и немедленно возвращается; главный поток исполнит её между текущим моментом и следующим кадром — обычно в пределах десятка миллисекунд. Русский запрос «fyne поток gui» почти всегда про эту функцию. Правила гигиены очереди: держи функцию короткой — пока она исполняется, интерфейс не рисует и не принимает ввод; собирай изменения пачкой в один Do вместо десяти мелких — каждая функция это прогон через очередь; и не рассчитывай, что вызов исполнится мгновенно — до кадра ещё дойти надо. Внутри fn ты снова в главном потоке со всеми правами: можно и SetText, и Refresh, и показать диалог. Внизу лестницы есть и технический этаж: fyne.Do — обёртка над Driver().DoFromGoroutine(fn, wait), где второй параметр и есть выбор между асинхронным и синхронным поведением; разработчику почти никогда не нужно звонить туда напрямую — знай, что этаж существует. А вот пара более удобных входов сверху: колбэки, которые тулкит вызывает сам, — события шестого урока, слушатели настроек девятнадцатого — уже приходят из главного потока, и оборачивать их не нужно; мост нужен только для горутин, которые создал ты сам.

### Загрузчик с прогрессом

```go
package main

import (
	"fmt"
	"time"

	"fyne.io/fyne/v2"
	"fyne.io/fyne/v2/app"
	"fyne.io/fyne/v2/container"
	"fyne.io/fyne/v2/widget"
)

func main() {
	myApp := app.NewWithID("io.gomaster.loader")
	window := myApp.NewWindow("Загрузка")

	status := widget.NewLabel("Готов к работе")
	bar := widget.NewProgressBar()
	start := widget.NewButton("Начать", func() {
		start.Disable()
		go func() {
			steps := 5
			for i := 1; i <= steps; i++ {
				time.Sleep(300 * time.Millisecond) // имитация работы
				done := float64(i) / float64(steps)
				fyne.Do(func() {
					bar.SetValue(done)
					status.SetText(fmt.Sprintf("Шаг %d из %d", i, steps))
				})
			}
			fyne.Do(func() {
				status.SetText("Готово")
				start.Enable()
			})
		}()
	})

	window.SetContent(container.NewVBox(status, bar, start))
	window.ShowAndRun()
}
```

Обработчик кнопки делает три вещи: отключает кнопку, чтобы не запустить вторую загрузку параллельно, уходит в горутину и через fyne.Do докладывает о прогрессе. Пока горутина спит и работает, интерфейс живёт: окно двигается, кнопки отзываются. Заметь: значения i и done вычислены в горутине, а в очередь уезжает готовый результат — в замыкании нет живых гонных переменных.

**Вопрос:** Что из перечисленного можно вызывать из собственной горутины БЕЗ fyne.Do?

- window.SetTitle("новый заголовок")
- canvas.Refresh(rect) для примитива на экране
- ✅ prefs.SetString("last.run", time) — запись в хранилище настроек
- dialog.ShowInformation("Готово", "...", window)

Хранилище настроек не рисует ничего: внутри у него мьютекс, и писать в него можно из любой горутины — слушателей предупредит сам. Всё остальное меняет экран или накладывает окно — территория главного потока. Канон семнадцатого урока работает и для привязок: само значение источника защищено, но каскад обновлений должен разъезжаться из главного потока.

### Привязки и потоки: где проходит граница

Семнадцатый урок оставил жёлтую рамку с обещанием разъяснить — выполняем. Источник привязки хранит значение под мьютексом: Get безопасен откуда угодно. Но Set запускает каскад: слушатели получают DataChanged в той же горутине, где случился Set, и если среди слушателей виджеты — их обновление уедет из твоей горутины прямиком в нарушение правила. Поэтому формула канона: писать в источники из фона — только через fyne.Do(func() { src.Set(v) }); тогда и значение, и весь каскад разъезжаются из главного потока, как будто пользователь нажал кнопку. Это же правило спасает DataListener из семнадцатого урока: колбэк слушателя исполняется в горутине Set, так что фоновая запись без обёртки тихо превратит твой логгер в гонщика. Полностью асинхронная альтернатива — не пускать фоновые данные напрямую в источники, а передавать их каналом и читать в главном потоке, но для типовых задач fyne.Do короче и читаемее. Отдельный случай — time.AfterFunc и тикеры: колбэк таймера исполняется в горутине рантайма, а не в главном потоке, так что таймер — тоже клиент fyne.Do. Проверь себя вопросом: кто создал goroutine, из которой вызван код? Если ответ «не тулкит» — нужен мост.

### DoAndWait и цена ожидания

fyne.DoAndWait(fn) — то же самое с одной разницей: вызывающая горутина ждёт, пока главный поток исполнит fn, и возвращается уже после обновления. Документация описывает окно исполнения честно: между моментом запроса и следующим кадром, то есть до ~15 миллисекунд ожидания. Когда ожидание оправдано? Когда следующий шаг горутины зависит от факта обновления: ты обновляешь буфер изображения и не хочешь начать новый тик, пока старый не отрисован; тесту нужно знать, что интерфейс уже увидел значение; два последовательных Do должны прийти строго в порядке написания кода. За это платишь синхронностью: горутина стоит, а главный поток в этот момент не рисует. И главное предостережение: DoAndWait — инструмент только для своих горутин. Если позвать его из обработчика кнопки, то есть из главного потока работающего приложения, получится классический дедлок: главный поток ждёт очередь, которую обслуживает он сам, а обслуживать её он сможет только выйдя из твоего обработчика. Окно замрёт намертво. Единственное исключение — вызов до старта цикла событий, когда главный поток ещё твой: тогда функция исполнится сразу, но в этом сценарии обычно ещё некому и городить горутины.

### Ожидание, которое вечно

```go
// в работающем приложении
start.OnTapped = func() {
	fyne.DoAndWait(func() { output.SetText("шаг 1") })
	fmt.Println("дождались")
}
```

**Что выведет программа?**

- Метка обновится, затем напечатается «дождались»
- «дождались» напечатается, затем метка обновится
- ✅ Окно замрёт: главный поток ждёт очередь, которую обслуживает он сам
- Fyne проигнорирует вызов и просто напечатает предупреждение

DoAndWait кладёт функцию в очередь и встаёт на ожидание. Но мы уже в главном потоке: очередь обслуживается тем же потоком, который сейчас заблокирован внутри OnTapped. Классический дедлок в три строки — поэтому правило звучит так: DoAndWait только из своих горутин, никогда из колбэков тулкита.

### Рецепты: таймер, сеть, отмена

Три сценария покрывают девять из десяти бытовых задач. Таймер: time.NewTicker в горутине и fyne.Do на каждый тик — часы, автосохранение раз в минуту, обновление курсов; на выходе из приложения тикер стоит остановить — окно закрылось, а горутина продолжает тикать вхолостую, храни stop-канал или контекст. Сеть: обработчик кнопки дёргает go func(), внутри — запрос стандартным http.Get, ответ разбираешь в горутине, а в главный поток уезжает уже готовая строка или структура пачкой в одном fyne.Do; не гоняй сырые байты через очередь по кусочкам — одна функция, одно обновление. Отмена: длинная задача обязана уметь останавливаться — context.WithCancel из обработчика кнопки «Стоп», select в рабочем цикле на ctx.Done(), по завершении fyne.Do со статусом; горутина, которую нельзя остановить, переживает окно и портит следующий запуск. Что выносить не стоит: всё, что быстрее пары миллисекунд, — мост дороже самой работы; событие — быстрый обработчик — Refresh, пропорция из шестого урока, не отменяется. Что выносить обязательно: чтение больших файлов, фильтрацию тысяч строк, что угодно с time.Sleep, сетевые вызовы — всё, за что пользователь платит замороженным окном.

### Отмена по контексту

```go
package main

import (
	"context"
	"fmt"
	"time"

	"fyne.io/fyne/v2"
	"fyne.io/fyne/v2/app"
	"fyne.io/fyne/v2/container"
	"fyne.io/fyne/v2/widget"
)

func main() {
	myApp := app.NewWithID("io.gomaster.worker")
	window := myApp.NewWindow("Фоновая задача")

	status := widget.NewLabel("Задача не запущена")
	start := widget.NewButton("Запустить", nil)
	stop := widget.NewButton("Остановить", nil)

	start.OnTapped = func() {
		ctx, cancel := context.WithCancel(context.Background())
		start.Disable()
		stop.Enable()

		go func() {
			for {
				select {
				case <-ctx.Done():
					fyne.Do(func() {
						status.SetText("Остановлено")
						start.Enable()
						stop.Disable()
					})
					return
				case <-time.After(time.Second):
					fmt.Println("порция работы выполнена")
				}
			}
		}()

		stop.OnTapped = func() {
			cancel()
		}
	}

	window.SetContent(container.NewVBox(status, start, stop))
	window.ShowAndRun()
}
```

Каждый запуск получает свой контекст и своё отменяющее замыкание. Рабочий цикл раз в секунду проверяет ctx.Done() — и по отмене возвращается в главный поток, чтобы обновить статус и кнопки. Горутина умирает до конца, ни одна её ветка не трогает виджеты напрямую. В реальном приложении вместо печати в консоль была бы порция настоящего дела.

**Вопрос:** Где исполняется функция, переданная в fyne.Do?

- В той же горутине, которая вызвала fyne.Do, — просто чуть позже
- ✅ В главном потоке GUI, между очередным моментом и ближайшим кадром отрисовки
- В отдельной горутине, которую Fyne создаёт под каждый вызов
- В горутине детектора гонок — fyne.Do работает только под -race

Вся суть моста в пересадке: куда бы ни позвали fyne.Do, тело функции исполнит главный поток в очередном прогоне цикла — между моментом запроса и следующим кадром, по документации до ~15 мс. Поэтому внутри fn можно трогать что угодно, а сам вызов считается асинхронным.

### Каталог потоков и мостов

| Объект | Что это | Как звать из своей горутины |
| --- | --- | --- |
| Главный поток GUI | горутина цикла событий после ShowAndRun | не звать вовсе — это твой дом, только мостами |
| Колбэки виджетов и событий | Tapped, OnChanged, колбэки диалогов | исполняются в главном потоке — интерфейс трогай напрямую |
| fyne.Do(fn) | асинхронная постановка в очередь главного потока | базовый мост для обновления UI |
| fyne.DoAndWait(fn) | то же с ожиданием исполнения | когда следующий шаг зависит от обновления; никогда из колбэков |
| Driver().DoFromGoroutine(fn, wait) | механизм под обе обёртки | почти никогда — fyne.Do короче и читаемее |
| Preferences (myApp.Preferences()) | хранилище настроек с внутренним мьютексом | Set и чтения безопасны напрямую |
| binding Get | чтение значения источника | безопасно из любой горутины |
| binding Set | запись, запускающая каскад слушателей | обёртывай в fyne.Do — канон семнадцатого урока |
| go run -race | детектор гонок данных | прогоняй каждый сценарий с горутинами |
| context.WithCancel | отмена фоновой задачи | кнопка «Стоп» пишет в контекст, задача умирает сама |

### Собери мост для фонового отчёта

Восстанови четыре пропуска: запуск горутины, мост в главный поток, обновление метки и синхронный вариант того же моста.

```go
___ {
	report := тяжёлыйРасчёт(числа)

	___(func() {
		output.___(report)
		output.Refresh()
	})

	fmt.Println("обновление поставлено в очередь")
}()

// а если следующий шаг требует уже обновлённой метки,
// замени fyne.Do на ___
```

Подсказка: Горутина запускается словом go, обновление из неё всегда через fyne.Do, у Label значение меняет SetText, а ждущий вариант моста называется DoAndWait.

Правильные ответы в пропусках: `go func()`, `fyne.Do`, `SetText`, `fyne.DoAndWait`

### Путь обновления из горутины до экрана

Расставь по порядку, что происходит, когда горутина с сетевым запросом обновляет метку.

Правильный порядок строк:

1. `горутина получила ответ из сети и собрала готовую строку`
2. `код вызвал fyne.Do(func() { label.SetText(готовая строка) })`
3. `функция встала в очередь главного потока GUI`
4. `главный поток дошёл до очереди и исполнил функцию между кадрами`
5. `виджет помечен изменённым и перерисовался в ближайшем кадре`
6. `горутина продолжила работу, не дожидаясь отрисовки`

> **Честно о запуске кода.** Реальный цикл событий с горутинами в браузерной песочнице курса не исполняется — GUI требует настоящего окна. Собери часы и загрузчик по [второму уроку](/courses/fyne/lessons/02-pervoe-okno/) и запусти их дважды: как есть и через go run -race. Один и тот же код без fyne.Do под детектором покажет гонку, о которой глаз на экране не догадался бы.

> **Три способа заморозить окно.** Дедлок номер один — fyne.DoAndWait из колбэка работающего приложения. Номер два — канал с небуферизованной отправкой в горутину, которую главный поток не читает, и наоборот: главному потоку нечего делать в ожидании чужой работы. Номер три — time.Sleep внутри обработчика: спит именно интерфейс. Все три выглядят одинаково для пользователя — окно-картинка, — но лечатся по-разному: мостом, буферизованным каналом с отбором и горутиной соответственно.

### Практика: чек-лист потоков приложения

Прежде чем выпускать приложение с горутинами, пройди по списку. Каждая длинная операция — сеть, файл, тяжёлый расчёт — запускается из обработчика через go func(), и ни одна не исполняется в самом обработчике дольше пары миллисекунд. Каждое касание экрана из фона идёт через fyne.Do, а пачки изменений сгруппированы в одну функцию. Каждая фоновая задача останавливаема: контекст или stop-канал, проверка в цикле, и по закрытию окна ничего не тикает впустую. Кнопки, запускающие горутины, отключаются на время работы — двойной запуск одной загрузки это не ускорение, а гонка с самим собой. Общий между горутиной и интерфейсом состояние либо передаётся значением через fyne.Do, либо защищено — sync.Mutex или атомарные типы, — и ни одна переменная не живёт в двух мирах без защиты. Прогон под go run -race выполнен на сценарии с реальной нагрузкой, а не на пустом окне. И последнее: не плодить горутин без нужды — тикер, который обновляет подпись раз в минуту, можно заменить проверкой при событии; каждый лишний параллельный агент это будущая гонка, которую кто-то однажды будет ловить под детектором.

### Что дальше: файлы без диалоговых блокировок

Теперь приложение многопоточно в обе стороны: интерфейс отзывчив, фоновые задачи возвращаются мостом, отмена встроена в каждую долгую операцию. Опора урока — [шестой](/courses/fyne/lessons/06-sobytiya/) с правилом главного потока и пропорцией «событие — быстрый обработчик — Refresh», [семнадцатый](/courses/fyne/lessons/17-binding/) с жёлтой рамкой про Set из чужой горутины, [тринадцатый](/courses/fyne/lessons/13-dialogi/) с диалогами, которые тоже живут в главном потоке, и [второй](/courses/fyne/lessons/02-pervoe-okno/) с каркасом ShowAndRun. Следующая тема — [файлы](/courses/fyne/lessons/21-fajly/): хранилище приложения fyne.Storage, URI со схемой file:// и диалоги выбора, чтение которых ты теперь обязан оборачивать в горутину, если файл большой. Канон склейки уже готов: открываешь файл — уходи в горутину, возвращаешься — через fyne.Do. Полный маршрут — [на странице курса Fyne](/courses/fyne/).

### Термины урока

- **Главный поток GUI** — Горутина, которая после ShowAndRun исполняет цикл событий. С версии 2.6.0 в ней проходят все колбэки тулкита; только здесь можно трогать виджеты и canvas напрямую.
- **fyne.Do(fn)** — Асинхронный мост: ставит функцию в очередь главного потока и сразу возвращается. Функция исполнится между запросом и следующим кадром — до ~15 мс.
- **fyne.DoAndWait(fn)** — Синхронный мост: горутина ждёт исполнения функции. Только для своих горутин — вызов из колбэка работающего приложения дедлокирует окно.
- **Гонка данных** — Два потока обращаются к одной памяти одновременно и один пишет. Лечится контрактом «один поток — один интерфейс» и мостами; ловится go run -race.
- **Set из горутины в binding** — Каскад слушателей исполняется в горутине вызова. Пиши через fyne.Do(func() { src.Set(v) }) — тогда обновления разъезжаются из главного потока.
- **Отмена по context.WithCancel** — Обязательный атрибут фоновой задачи: кнопка «Стоп» вызывает cancel, рабочий цикл проверяет ctx.Done() и по завершении возвращается через fyne.Do со статусом.

## Коротко: что запомнить

- У приложения один поток-владелец: после ShowAndRun главный goroutine крутит цикл событий, и с версии 2.6.0 все колбэки тулкита исполняются именно в нём.
- Обновление UI из своей горутины без моста — гонка данных: исход непредсказуем, от удачи до падения; детектор go run -race обязателен для таких сценариев.
- fyne.Do(fn) — асинхронный мост: функция уезжает в очередь главного потока и исполняется до следующего кадра; собирай изменения пачками и держи функции короткими.
- fyne.DoAndWait(fn) ждёт исполнения — для случаев, когда следующий шаг зависит от обновления; из колбэков работающего приложения это дедлок.
- Preferences защищены мьютексом и пишутся из любой горутины; привязки пиши через fyne.Do — каскад слушателей должен разъезжаться из главного потока.
- Три рецепта покрывают быт: тикер с fyne.Do на каждый тик, сетевой запрос с готовой пачкой в одном Do, отмена по context.WithCancel.
- Чек-лист: длинное — в горутину, экран — только через мост, задачи останавливаемы, кнопки отключаются, общее состояние защищено, -race прогнан под нагрузкой.

## Домашнее задание

Собери менеджер фоновой обработки текста. Вход — многострочный Entry из девятого урока и три кнопки: «Статистика», «Искать», «Стоп». Кнопка «Статистика» уходит в горутину и считает, сколько в тексте слов, строк и символов, затем через fyne.Do обновляет три метки одним вызовом. Кнопка «Искать» ищет подстроку по тексту в горутине с прогрессом: разбей текст на куски по абзацам и после каждого обновляй ProgressBar через fyne.Do, чтобы окно оставалось живым. «Стоп» отменяет поиск через context.WithCancel, горутина обязана выйти и через fyne.Do пометить статус «Остановлено». Добавь часы в заголовок окна на time.NewTicker с fyne.Do — и не забудь остановить тикер, когда приложение закрывается, перехватив закрытие через SetCloseIntercept из второго урока. Проверь себя под детектором: запусти go run -race и понажимай кнопки на большом тексте; ноль предупреждений — цель. Ловушка для самопроверки: сначала сделай версию «Статистики» без горутины и замерь, на каком тексте окно начинает замирать; этот порог и есть твоя личная граница «быстро — в обработчике, дольше — в горутину».

## Вопросы и ответы по уроку

### Почему Fyne не защищает виджеты мьютексами, как другие библиотеки?

Замок на каждый вызов умножил бы стоимость отрисовки: за кадр виджеты читаются десятки раз. Вместо этого тулкит фиксирует контракт «один поток — один интерфейс» и даёт дешёвые мосты fyne.Do/DoAndWait, а колбэки сам доставляет в главный поток — так быстрее и предсказуемее.

### Можно ли вызывать fyne.Do до старта цикла событий, например в main до ShowAndRun?

Можно, но бессмысленно: до запуска цикла главный поток ещё твой, и вызов из него исполнится сразу, без очереди. Мост нужен после ShowAndRun — когда главный поток занят циклом событий, а твой код живёт в собственной горутине.

### Что безопаснее для передачи данных из фона: fyne.Do или канал?

Для обновления экрана — fyne.Do: короче и без шаблонного кода с каналом. Канал стоит брать, когда данных много и они текут потоком: горутина пишет в канал, главный поток читает в цикле и обновляет раз в N сообщений — так очередь интерфейса не забивается тысячами мелких функций.
