Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 59bfcfa

Browse files
authored
Merge pull request #18565 from richardsheridan/tk_blit_thread_safe
Make Tkagg blit thread safe
2 parents 2ca5ed1 + 0b1660b commit 59bfcfa

File tree

3 files changed

+146
-9
lines changed

3 files changed

+146
-9
lines changed

lib/matplotlib/backends/_backend_tk.py

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import uuid
12
from contextlib import contextmanager
23
import logging
34
import math
@@ -44,6 +45,28 @@ def _restore_foreground_window_at_end():
4445
_c_internal_utils.Win32_SetForegroundWindow(foreground)
4546

4647

48+
_blit_args = {}
49+
# Initialize to a non-empty string that is not a Tcl command
50+
_blit_tcl_name = "mpl_blit_" + uuid.uuid4().hex
51+
52+
53+
def _blit(argsid):
54+
"""
55+
Thin wrapper to blit called via tkapp.call.
56+
57+
*argsid* is a unique string identifier to fetch the correct arguments from
58+
the ``_blit_args`` dict, since arguments cannot be passed directly.
59+
60+
photoimage blanking must occur in the same event and thread as blitting
61+
to avoid flickering.
62+
"""
63+
photoimage, dataptr, offsets, bboxptr, blank = _blit_args.pop(argsid)
64+
if blank:
65+
photoimage.blank()
66+
_tkagg.blit(
67+
photoimage.tk.interpaddr(), str(photoimage), dataptr, offsets, bboxptr)
68+
69+
4770
def blit(photoimage, aggimage, offsets, bbox=None):
4871
"""
4972
Blit *aggimage* to *photoimage*.
@@ -53,7 +76,10 @@ def blit(photoimage, aggimage, offsets, bbox=None):
5376
(2, 1, 0, 3) for little-endian ARBG32 (i.e. GBRA8888) data and (1, 2, 3, 0)
5477
for big-endian ARGB32 (i.e. ARGB8888) data.
5578
56-
If *bbox* is passed, it defines the region that gets blitted.
79+
If *bbox* is passed, it defines the region that gets blitted. That region
80+
will NOT be blanked before blitting.
81+
82+
Tcl events must be dispatched to trigger a blit from a non-Tcl thread.
5783
"""
5884
data = np.asarray(aggimage)
5985
height, width = data.shape[:2]
@@ -65,11 +91,31 @@ def blit(photoimage, aggimage, offsets, bbox=None):
6591
y1 = max(math.floor(y1), 0)
6692
y2 = min(math.ceil(y2), height)
6793
bboxptr = (x1, x2, y1, y2)
94+
blank = False
6895
else:
69-
photoimage.blank()
7096
bboxptr = (0, width, 0, height)
71-
_tkagg.blit(
72-
photoimage.tk.interpaddr(), str(photoimage), dataptr, offsets, bboxptr)
97+
blank = True
98+
99+
# NOTE: _tkagg.blit is thread unsafe and will crash the process if called
100+
# from a thread (GH#13293). Instead of blanking and blitting here,
101+
# use tkapp.call to post a cross-thread event if this function is called
102+
# from a non-Tcl thread.
103+
104+
# tkapp.call coerces all arguments to strings, so to avoid string parsing
105+
# within _blit, pack up the arguments into a global data structure.
106+
args = photoimage, dataptr, offsets, bboxptr, blank
107+
# Need a unique key to avoid thread races.
108+
# Again, make the key a string to avoid string parsing in _blit.
109+
argsid = str(id(args))
110+
_blit_args[argsid] = args
111+
112+
try:
113+
photoimage.tk.call(_blit_tcl_name, argsid)
114+
except tk.TclError as e:
115+
if "invalid command name" not in str(e):
116+
raise
117+
photoimage.tk.createcommand(_blit_tcl_name, _blit)
118+
photoimage.tk.call(_blit_tcl_name, argsid)
73119

74120

75121
class TimerTk(TimerBase):
@@ -402,10 +448,18 @@ def destroy(self, *args):
402448
if self.canvas._idle_callback:
403449
self.canvas._tkcanvas.after_cancel(self.canvas._idle_callback)
404450

405-
self.window.destroy()
451+
# NOTE: events need to be flushed before issuing destroy (GH #9956),
452+
# however, self.window.update() can break user code. This is the
453+
# safest way to achieve a complete draining of the event queue,
454+
# but it may require users to update() on their own to execute the
455+
# completion in obscure corner cases.
456+
def delayed_destroy():
457+
self.window.destroy()
458+
459+
if self._owns_mainloop and not Gcf.get_num_fig_managers():
460+
self.window.quit()
406461

407-
if self._owns_mainloop and not Gcf.get_num_fig_managers():
408-
self.window.quit()
462+
self.window.after_idle(delayed_destroy)
409463

410464
def get_window_title(self):
411465
return self.window.wm_title()

lib/matplotlib/backends/backend_tkagg.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
class FigureCanvasTkAgg(FigureCanvasAgg, FigureCanvasTk):
88
def draw(self):
99
super().draw()
10-
_backend_tk.blit(self._tkphoto, self.renderer._renderer, (0, 1, 2, 3))
10+
self.blit()
1111

1212
def blit(self, bbox=None):
1313
_backend_tk.blit(

lib/matplotlib/tests/test_backends_interactive.py

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@ def _get_testable_interactive_backends():
6767
# early. Also, gtk3 redefines key_press_event with a different signature, so
6868
# we directly invoke it from the superclass instead.
6969
_test_script = """\
70-
import importlib
7170
import importlib.util
7271
import io
7372
import json
@@ -173,6 +172,90 @@ def test_interactive_backend(backend, toolbar):
173172
assert proc.stdout.count("CloseEvent") == 1
174173

175174

175+
_thread_test_script = """\
176+
import json
177+
import sys
178+
import threading
179+
180+
from matplotlib import pyplot as plt, rcParams
181+
rcParams.update({
182+
"webagg.open_in_browser": False,
183+
"webagg.port_retries": 1,
184+
})
185+
if len(sys.argv) >= 2: # Second argument is json-encoded rcParams.
186+
rcParams.update(json.loads(sys.argv[1]))
187+
188+
# Test artist creation and drawing does not crash from thread
189+
# No other guarantees!
190+
fig, ax = plt.subplots()
191+
# plt.pause needed vs plt.show(block=False) at least on toolbar2-tkagg
192+
plt.pause(0.5)
193+
194+
exc_info = None
195+
196+
def thread_artist_work():
197+
try:
198+
ax.plot([1,3,6])
199+
except:
200+
# Propagate error to main thread
201+
import sys
202+
global exc_info
203+
exc_info = sys.exc_info()
204+
205+
def thread_draw_work():
206+
try:
207+
fig.canvas.draw()
208+
except:
209+
# Propagate error to main thread
210+
import sys
211+
global exc_info
212+
exc_info = sys.exc_info()
213+
214+
t = threading.Thread(target=thread_artist_work)
215+
t.start()
216+
# artists never wait for the event loop to run, so just join
217+
t.join()
218+
219+
if exc_info: # Raise thread error
220+
raise exc_info[1].with_traceback(exc_info[2])
221+
222+
t = threading.Thread(target=thread_draw_work)
223+
fig.canvas.mpl_connect("close_event", print)
224+
t.start()
225+
plt.pause(0.5) # flush_events fails here on at least Tkagg (bpo-41176)
226+
t.join()
227+
plt.close()
228+
fig.canvas.flush_events() # pause doesn't process events after close
229+
230+
if exc_info: # Raise thread error
231+
raise exc_info[1].with_traceback(exc_info[2])
232+
"""
233+
234+
_thread_safe_backends = _get_testable_interactive_backends()
235+
# Known unsafe backends. Remove the xfails if they start to pass!
236+
if "wx" in _thread_safe_backends:
237+
_thread_safe_backends.remove("wx")
238+
_thread_safe_backends.append(
239+
pytest.param("wx", marks=pytest.mark.xfail(
240+
raises=subprocess.CalledProcessError, strict=True)))
241+
if "macosx" in _thread_safe_backends:
242+
_thread_safe_backends.remove("macosx")
243+
_thread_safe_backends.append(
244+
pytest.param("macosx", marks=pytest.mark.xfail(
245+
raises=subprocess.TimeoutExpired, strict=True)))
246+
247+
248+
@pytest.mark.parametrize("backend", _thread_safe_backends)
249+
@pytest.mark.flaky(reruns=3)
250+
def test_interactive_thread_safety(backend):
251+
proc = subprocess.run(
252+
[sys.executable, "-c", _thread_test_script],
253+
env={**os.environ, "MPLBACKEND": backend, "SOURCE_DATE_EPOCH": "0"},
254+
timeout=_test_timeout, check=True,
255+
stdout=subprocess.PIPE, universal_newlines=True)
256+
assert proc.stdout.count("CloseEvent") == 1
257+
258+
176259
@pytest.mark.skipif('TF_BUILD' in os.environ,
177260
reason="this test fails an azure for unknown reasons")
178261
@pytest.mark.skipif(os.name == "nt", reason="Cannot send SIGINT on Windows.")

0 commit comments

Comments
 (0)