Skip to content

Commit a27d6d9

Browse files
committed
Use 'willdispatch' to enable multithreading support on CPython
1 parent 1f612e1 commit a27d6d9

3 files changed

Lines changed: 216 additions & 8 deletions

File tree

README.md

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
Easy multithreading with Tkinter on CPython 2.7/3.x and PyPy 2.7/3.x.
44

5+
import tkthread; tkthread.tkinstall() # do this before importing tkinter
6+
57
## Background
68

79
The Tcl/Tk language that comes with Python follows a different threading
@@ -13,7 +15,7 @@ threads with Tkinter, such as:
1315
NotImplementedError: Call from another thread
1416

1517
Tcl can have many isolated interpreters, and each are tagged to the
16-
its particular OS thread when created. Python's _tkinter module checks
18+
its particular OS thread when created. Python's `_tkinter` module checks
1719
if the calling Python thread is different than the Tcl/Tk thread, and if so,
1820
[waits one second][WaitForMainloop] for the Tcl/Tk main loop to begin
1921
dispatching. If there is a timeout, a RuntimeError is raised. On PyPy,
@@ -26,12 +28,17 @@ A common approach to avoid these errors involves using `.after` to set up
2628
[periodic polling][PollQueue] of a [message queue][PollRecipe] from
2729
the Tcl/Tk main loop, which can slow the responsiveness of the GUI.
2830

29-
The approach used in `tkthread` is to use the Tcl/Tk `thread::send`
31+
The current approach used in `tkthread` is to use the Tcl/Tk `thread::send`
3032
messaging to notify the Tcl/Tk main loop of a call for execution.
3133
This interrupt-style architecture has lower latency and better
32-
CPU utilization than periodic polling.
34+
CPU utilization than periodic polling. This works with CPython and PyPy.
35+
36+
The experimental approach used in `tkthread` is to use `tkthread.tkinstall()`
37+
to patch Tkinter when make calls into Tcl/Tk. This only works on CPython and
38+
it does not require the `Thread` package in Tcl.
39+
3340

34-
## Usage
41+
## Usage on CPython/PyPy
3542

3643
The `tkthread` module provides the `TkThread` class, which can
3744
synchronously interact with the main thread.
@@ -78,10 +85,57 @@ A good practice is to create a root window and then call `root.withdraw()`
7885
to keep the primary Tcl/Tk interpreter active. Future Toplevel windows
7986
use `root` as the master.
8087

88+
89+
## Experimental Usage on CPython (simpler)
90+
91+
For CPython 2.7/3.x, `tkhread.tkinstall()` can be called first,
92+
and will patch Tkinter to re-route threaded calls to the Tcl interpreter
93+
using the "willdispatch" internal API call.
94+
95+
import tkthread; tkthread.tkinstall()
96+
import tkinter as tk
97+
98+
root = tk.Tk()
99+
100+
import threading
101+
def thread_run(func): threading.Thread(target=func).start()
102+
103+
@thread_run
104+
def func():
105+
root.wm_title(threading.current_thread())
106+
107+
@tkthread.main(root)
108+
@tkthread.current(root)
109+
def testfunc():
110+
tk.Label(text=threading.current_thread()).pack()
111+
112+
root.mainloop()
113+
114+
81115
## Install
82116

83117
pip install tkthread
84118

119+
120+
## Known Errors
121+
122+
You may receive this error when using `tkthread.TkThread(root)`:
123+
124+
_tkinter.TclError: can't find package Thread
125+
126+
This means that Python's Tcl/Tk libraries do not include the `Thread` package,
127+
which is needed by `TkThread`.
128+
129+
On Debian/Ubuntu:
130+
131+
`apt install tcl-thread`
132+
133+
On Windows, you'll need to manually update your Tcl installation to include
134+
the `Thread` package.
135+
136+
The simpler solution is to use `tkthread.tkinstall()` instead.
137+
138+
85139
## License
86140

87141
Licensed under the Apache License, Version 2.0 (the "License")

tkthread/__init__.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#
2-
# Copyright 2018 Roger D. Serwy
2+
# Copyright 2018, 2021 Roger D. Serwy
33
#
44
# Licensed under the Apache License, Version 2.0 (the "License");
55
# you may not use this file except in compliance with the License.
@@ -32,8 +32,36 @@
3232
3333
This module allows Python multithreading to cooperate with Tkinter.
3434
35-
Usage
36-
-----
35+
36+
Experimental Usage CPython
37+
--------------------------
38+
39+
For CPython 2.7/3.x, `tkhread.tkinstall()` can be called first,
40+
and will patch Tkinter to re-route calls to the main thread,
41+
using the "willdispatch" internal API call.
42+
43+
import tkthread; tkthread.tkinstall()
44+
import tkinter as tk
45+
46+
root = tk.Tk()
47+
48+
import threading
49+
def thread_run(func): threading.Thread(target=func).start()
50+
51+
@thread_run
52+
def func():
53+
root.wm_title('WORKS')
54+
print(threading.current_thread())
55+
56+
@tkthread.main(root) # run on main thread
57+
@tkthread.current(root) # run on current thread
58+
def testfunc():
59+
tk.Label(text=threading.current_thread()).pack()
60+
61+
root.mainloop()
62+
63+
Usage CPython/Pypy
64+
------------------
3765
3866
The `tkthread` module provides the `TkThread` class,
3967
which can synchronously interact with the main thread.
@@ -74,8 +102,10 @@ def run(func):
74102
import queue
75103

76104
from ._version import __version__
105+
from ._willdispatch import tkinstall, main, current
106+
107+
__all__ = ['TkThread', 'tk', '__version__', 'tkinstall', 'main', 'current']
77108

78-
__all__ = ['TkThread', 'tk', '__version__']
79109

80110
class _Result(object):
81111
"""Cross-thread synchronization of a result"""

tkthread/_willdispatch.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
#
2+
# Copyright 2021 Roger D. Serwy
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
#
16+
"""
17+
_willdispatch.py
18+
19+
20+
This Tkinter multithreading support makes use of the undocumented
21+
`.willdispatch()` function that then bypasses the `WaitForMainloop()`
22+
C function in _tkinter.c.
23+
24+
The C code already checks the running thread and serializes the
25+
request to the main thread with `Tkapp_ThreadSend()`. That
26+
function acquires a mutex before adding the event to the Tcl event queue.
27+
28+
There is a threading race condition, where `disptaching` can be set to
29+
zero before the thread reads it. This is why there is a retry loop on
30+
the function call.
31+
32+
"""
33+
from __future__ import print_function
34+
import traceback
35+
import sys
36+
37+
from . import tk
38+
39+
class _tk_dispatcher(object):
40+
""" Force `WaitForMainloop` to return 1"""
41+
_RETRY_COUNT = 10
42+
43+
def __init__(self, tk):
44+
self.__tk = tk
45+
46+
def __getattr__(self, name):
47+
return getattr(self.__tk, name)
48+
49+
50+
# ---------------------------
51+
# create the needed functions
52+
# ---------------------------
53+
54+
def _make_func(name):
55+
def wrapped(self, *args, **kwargs):
56+
_tk = self.__tk
57+
func = getattr(_tk, name)
58+
rcount = _tk_dispatcher._RETRY_COUNT
59+
for n in range(rcount + 1):
60+
_tk.willdispatch()
61+
try:
62+
result = func(*args, **kwargs)
63+
break
64+
except RuntimeError as exc:
65+
# There is a race condition between this thread
66+
# and the main thread event loop setting dispatch=0.
67+
# As rare as the condition is, it needs to be handled.
68+
if n < rcount:
69+
traceback.print_exc(file=sys.stderr)
70+
print('retrying %r %i of %i' % (name, n+1, rcount),
71+
file=sys.stderr)
72+
continue
73+
else:
74+
raise
75+
return result
76+
return wrapped
77+
78+
79+
_locals = locals()
80+
for _name in ['call', 'createcommand', 'deletecommand',
81+
'setvar', 'unsetvar', 'getvar',
82+
'globalsetvar', 'globalunsetvar', 'globalgetvar']:
83+
_locals[_name] = _make_func(_name)
84+
85+
del _locals
86+
del _name
87+
del _make_func
88+
89+
90+
class _Tk(tk.Tk):
91+
def __init__(self, *args, **kw):
92+
tk._Tk_original_.__init__(self, *args, **kw)
93+
# patch the existing Tkapp object
94+
self.tk = _tk_dispatcher(self.tk)
95+
96+
97+
def tkinstall():
98+
"""Replace tkinter's `Tk` class with a thread-enabled version"""
99+
import platform
100+
runtime = platform.python_implementation()
101+
if 'cpython' not in runtime.lower():
102+
raise RuntimeError('Requires CPython, not %s' % runtime)
103+
104+
tk.__dict__.setdefault('_Tk_original_', tk.Tk) # save the original version
105+
if tk.Tk is tk._Tk_original_:
106+
tk.Tk = _Tk
107+
108+
109+
def main(widget):
110+
"""Decorator to run callable on Tcl/Tk mainthread."""
111+
def wrapped(func):
112+
widget.tk.willdispatch()
113+
widget.after(0, func)
114+
return func
115+
return wrapped
116+
117+
118+
def current(widget):
119+
"""Decorator to run callable on the current thread.
120+
Useful for quickly changing with `tkthread.main`"""
121+
def wrapped(func):
122+
func()
123+
return func
124+
return wrapped

0 commit comments

Comments
 (0)