Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 20 из 25 ~20 мин +50 XP

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

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

Содержание урока · 12 разделов

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

У приложения на Fyne есть поток-владелец: главный goroutine, который после ShowAndRun из каркаса второго урока перестаёт быть твоим и становится циклом событий. Он выбирает уведомления операционной системы, определяет, над каким объектом курсор или палец, вызывает твои колбэки и раз в кадр отрисовывает то, что помечено изменённым. С версии 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) прямо в горутине с тикером?

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

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

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

Прочитайте код и выберите, что он выведет:

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

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?

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

Семнадцатый урок оставил жёлтую рамку с обещанием разъяснить — выполняем. Источник привязки хранит значение под мьютексом: 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 — инструмент только для своих горутин. Если позвать его из обработчика кнопки, то есть из главного потока работающего приложения, получится классический дедлок: главный поток ждёт очередь, которую обслуживает он сам, а обслуживать её он сможет только выйдя из твоего обработчика. Окно замрёт намертво. Единственное исключение — вызов до старта цикла событий, когда главный поток ещё твой: тогда функция исполнится сразу, но в этом сценарии обычно ещё некому и городить горутины.

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

Прочитайте код и выберите, что он выведет:

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

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

Три сценария покрывают девять из десяти бытовых задач. Таймер: 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?

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

ОбъектЧто этоКак звать из своей горутины
Главный поток 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отмена фоновой задачикнопка «Стоп» пишет в контекст, задача умирает сама

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

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

{ report := тяжёлыйРасчёт(числа) (func() { output.(report) output.Refresh() }) fmt.Println("обновление поставлено в очередь") }() // а если следующий шаг требует уже обновлённой метки, // замени fyne.Do на

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

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

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

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

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

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

Теперь приложение многопоточно в обе стороны: интерфейс отзывчив, фоновые задачи возвращаются мостом, отмена встроена в каждую долгую операцию. Опора урока — шестой с правилом главного потока и пропорцией «событие — быстрый обработчик — Refresh», семнадцатый с жёлтой рамкой про Set из чужой горутины, тринадцатый с диалогами, которые тоже живут в главном потоке, и второй с каркасом ShowAndRun. Следующая тема — файлы: хранилище приложения fyne.Storage, URI со схемой file:// и диалоги выбора, чтение которых ты теперь обязан оборачивать в горутину, если файл большой. Канон склейки уже готов: открываешь файл — уходи в горутину, возвращаешься — через fyne.Do. Полный маршрут — на странице курса Fyne.

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

Термин

Главный поток GUI

нажмите, чтобы перевернуть
Определение

Горутина, которая после ShowAndRun исполняет цикл событий. С версии 2.6.0 в ней проходят все колбэки тулкита; только здесь можно трогать виджеты и canvas напрямую.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Виртуальный экран

HTML-модель виджет-дерева fyne
fyne.Do и главный потокfyne · мок

Фоновая горутина считает…

40%
Журнал событий

Экран собран: «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 · Многопоточность в GUI)

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

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

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

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

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

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

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

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

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Cobra25 уроков~10 чпрактика25 уроков уже доступны

Cobra: CLI-приложения на Go

Интерактивный курс Cobra — фреймворка для CLI на Go: команды, флаги, конфиги Viper, автодополнение. 25 уроков с примерами кода, тестами и итоговым приложением.

  • Создавать CLI-приложения с деревом команд на Cobra
  • Работать с аргументами, флагами и их привязкой к конфигу
  • Управлять конфигурацией через Viper: файлы, env, приоритеты
  • Писать хуки PreRun/PostRun и корректно обрабатывать ошибки