Racket 编程入门

第 5 部分 · 对象与界面

GUI 不卡顿——把耗时任务挪到后台线程

点按钮卡死窗口,多半不是 Racket 慢,而是耗时任务跑错了线程——挪到后台线程,再用 queue-callback 或 channel 把结果递回去,窗口就不卡了。

你写过带界面的程序,知道按钮一点就该有反应。你也写过耗时的活儿——读文件、跑计算、等网络。把这两件事塞进同一个按钮回调,窗口就僵住了:按钮按下去不回弹,拖动边框没反应,连关闭都点不动。

为什么界面这么脆弱?因为 GUI 程序天生靠一条事件循环活着。

卡顿的根源:事件循环只有一条道

Racket 的 GUI 把所有事件——鼠标点击、键盘输入、窗口重绘、按钮回调——排成一队,交给一条 handler 线程(事件派发线程)一个一个处理。前一个回调没返回,后一个事件就进不来。

官方文档用这样一个按钮演示卡顿:回调里只做一件事,sleep 五秒。

#lang racket/gui

(define frame (new frame% [label "卡顿演示"] [width 300] [height 80]))
(define msg (new message% [parent frame] [label "就绪"] [auto-resize #t]))

(new button%
 [parent frame]
 [label "暂停"]
 [callback (λ (button event)
 (sleep 5) ; 整个窗口定住 5 秒
 (send msg set-label "恢复"))])

(send frame show #t)

点一下,整个窗口死 5 秒。sleep 把 handler 线程占住了,事件队列堵成一团。换成真正的耗时计算——解析 JSON、跑算法、拉网络——效果一样:GUI 假死。

handler 线程在跑你的回调时,不会再派发任何事件——窗口就定在那里。 所以规矩只有一条:回调里别干重活。

把耗时任务扔进新线程

解法是把重活从回调里搬出去。thread 接一个无参函数(thunk),开一条新线程跑它,自己立刻返回。回调瞬间结束,handler 线程马上空出来去派发下一个事件。

#lang racket/gui

(define frame (new frame% [label "后台线程"] [width 300] [height 80]))
(define msg (new message% [parent frame] [label "就绪"] [auto-resize #t]))

(new button%
 [parent frame]
 [label "开始"]
 [callback
 (λ (button event)
 (send msg set-label "计算中…")
 (thread ; 开后台线程跑重活,回调立即返回
 (λ ()
 (sleep 3) ; 模拟耗时计算
 (send msg set-label "完成"))))]) ; ← 这里藏着一个坑

(send frame show #t)

窗口不卡了。但代码里埋了个坑:后台线程直接调了 (send msg set-label ...)。

回到主线程:queue-callback

后台线程到底能不能碰 GUI?技术上能——GUI 对象是线程安全的,跨线程调用不会崩。但你的后台线程和 handler 线程上的回调可能同时改同一个控件,状态会打架,调试起来一头雾水。所以官方的规矩是:GUI 的活儿只在 handler 线程做,别的线程要用 queue-callback 把活儿递回去。

queue-callback 接一个无参函数,把它丢进当前 eventspace 的事件队列,由 handler 线程在下一个空档执行。它本身立刻返回(返回值是 void),不等回调跑完。

(new button%
 [parent frame]
 [label "开始"]
 [callback
 (λ (button event)
 (send msg set-label "计算中…")
 (thread
 (λ ()
 (sleep 3)
 ;; 不直接碰 msg:把更新递回 handler 线程
 (queue-callback
 (λ () (send msg set-label "完成"))))))])

queue-callback 还能接第二个参数:传 #f 表示低优先级(默认 #t 高优先级),让真正的用户输入事件先走。

GUI 的活儿只在 handler 线程做;别的线程要用 queue-callback 把活儿递回去。 这是跨线程更新控件唯一稳妥的路子。

流式进度:channel 在线程间递东西

一次性的“完成”好办,queue-callback 一递就完事。但如果后台任务要不断吐进度——每算完一段就刷新一下显示——你需要一条管道把结果从后台线程送到前台。这就是 channel。

make-channel 造一条通道,channel-put 往里放值,channel-get 从里取值。channel 是同步的:放和取必须配对,先到的一方会阻塞,等另一方来接头。这条性质决定了 channel 不能由 handler 线程来读——channel-get 一阻塞,窗口又卡了。

正确做法:开一条专门的读取线程待命,它从 channel 取到一个值,就调一次 queue-callback 刷新界面。

#lang racket/gui

(define frame (new frame% [label "进度"] [width 300] [height 80]))
(define msg (new message% [parent frame] [label "就绪"] [auto-resize #t]))
(define ch (make-channel))

;; 待命的读取线程:取到一个值,就递一条 GUI 更新
(thread
 (λ ()
 (let loop ()
 (define v (channel-get ch)) ; 阻塞,等后台放进一个值
 (unless (eq? v 'done)
 (queue-callback
 (λ () (send msg set-label (format "进度 ~a/10" v))))
 (loop)))))

(new button%
 [parent frame]
 [label "开始"]
 [callback
 (λ (button event)
 (thread ; 后台线程:干活,把进度一步步放进 channel
 (λ ()
 (for ([i (in-range 1 11)])
 (sleep 0.3)
 (channel-put ch i))
 (channel-put ch 'done))))])

(send frame show #t)

读取线程是普通线程,它阻塞在 channel-get 上不影响任何人;后台线程只管生产,界面只管显示,两边的节奏由 channel 接合。

channel 是同步的:先到的一方会卡住,等另一方来接头。 这条性质决定了它得配一条专职读取线程,而不是让 handler 线程去等。

在回调里等结果:yield,不要忙等

有时候你确实需要在按钮回调里拿到后台结果——比如点“导出”,等后台跑完再提示“完成”。回调里直接 sleep 会卡死窗口,用 (channel-get ch) 等结果同样会卡死。handler 线程需要一种“一边等结果、一边还能响应事件”的等法。

这就是 yield。传给它一个可同步事件(channel 就是),它会在 handler 线程里一边派发 GUI 事件、一边等事件就绪。

#lang racket/gui

(define (do-export) ; 假装这是个耗时的导出
 (sleep 3)
 "export.dat")

(define frame (new frame% [label "yield"] [width 300] [height 80]))
(define msg (new message% [parent frame] [label "就绪"] [auto-resize #t]))

(new button%
 [parent frame]
 [label "导出"]
 [callback
 (λ (button event)
 (define result-ch (make-channel))
 (thread ; 后台跑导出
 (λ () (channel-put result-ch (do-export))))
 (send msg set-label "导出中…")
 (define r (yield result-ch)) ; 不卡:期间照常响应事件
 (send msg set-label (format "完成:~a" r)))])

(send frame show #t)

有个坑要知道:yield 等的时候,用户还能再点这个按钮(事件被嵌套派发了)。需要防重复点击,就在按钮上加个“正在导出”的状态位。

顺带一提,(sleep/yield secs) 是 sleep 的 GUI 友好版:在 handler 线程里它会边睡边派发事件,在别的线程里和普通 sleep 一样。

在 handler 线程里要等东西,永远用 (yield evt),别用 sleep 或忙等。

给后台任务一个独立的事件循环:eventspace

普通 thread 能搬走纯计算,已经解决大多数卡顿。但有一种场景它搞不定:后台任务本身就是事件驱动的——它要用定时器、要弹自己的对话框、要 yield 等东西。这些事件操作都绑在 eventspace 上。如果还在主 eventspace 里折腾,它的 yield 和定时器仍跑在主 handler 线程上,绕一圈又回来了。

make-eventspace 造一个新的事件空间,它自带一条独立的 handler 线程。用 parameterize 把 current-eventspace 切过去,在里面创建的窗口、定时器、回调都归这个新空间管,由它自己的 handler 线程派发。

#lang racket/gui

(define bg-es (make-eventspace)) ; 新 eventspace,自带一条 handler 线程

(parameterize ([current-eventspace bg-es])
 ;; 这只定时器挂在 bg-es 名下,notify 由 bg-es 的 handler 线程派发
 (define heartbeat
 (new timer%
 [interval 500]
 [notify-callback (λ () (displayln "后台心跳"))])))

(define frame (new frame% [label "eventspace"] [width 320] [height 80]))
(new message%
 [parent frame]
 [label "主窗口和后台定时器各走各的"]
 [auto-resize #t])
(send frame show #t)

不同 eventspace 的事件由各自的 handler 线程异步派发,彼此不抢同一条道。后台定时器就算 notify 里磨蹭一会儿,也只耽误它自己的 handler 线程,主窗口照常丝滑。要彻底收摊,把 bg-es 的 custodian 一关,它名下的窗口、定时器、排队回调全清。

不同 eventspace 的事件,由各自的 handler 线程异步派发——彼此不抢同一条道。

五件工具,各管一段

工具作用在哪条线程
thread把耗时计算搬离 handler 线程新开的线程
queue-callback把“改 GUI”的活儿递回 handler 线程调用方立刻返回,活儿在 handler 线程跑
channel线程间同步传值放和取各在一条线程,配对才成交
yield在 handler 线程里边等边响应事件handler 线程
make-eventspace给后台任务一条独立的事件循环新的 handler 线程

记住两条线:耗时任务别在 handler 线程里跑,开了后台线程就用 queue-callback 把 GUI 的活儿递回去——界面永远只在它自己的 handler 线程里被碰。