Skip to content

Commit

Permalink
psutil.cpu_percent() is not thread safe (fixes #1703) (#2282)
Browse files Browse the repository at this point in the history
`psutil.cpu_percent(interval=None)` and `psutil.cpu_times_percent(interval=None)` are non-blocking functions returning the CPU percent consumption since the last time they were called. In order to do so they use a global variable, in which the last CPU timings are saved, and that means they are not thread safe. E.g., if 10 threads call `cpu_percent(interval=None)` with a 1 second interval, only 1 thread out of 10 will get the right result, as it will "invalidate" the timings for the other 9. Problem can be reproduced with the following script:

```python
import threading, time, psutil

NUM_WORKERS = psutil.cpu_count()

def measure_cpu():
    while 1:
        print(psutil.cpu_percent())
        time.sleep(1)

for x in range(NUM_WORKERS):
    threading.Thread(target=measure_cpu).start()
while 1:
    print()
    time.sleep(1.1)
```

The output looks like this, and it shows how inconsistent CPU measurements are between different threads (notice 0.0 values):

```
3.5
3.5
0.0
0.0

2.8
2.8
0.0
0.0

2.5
2.5
0.0
0.0

2.5
2.5
2.5
2.5

3.3
3.3
3.3
50.0

2.8
0.0
0.0
0.0
```

After patch:

```
0.0
0.0
0.0

0.0
2.0
2.3
2.3
2.3

5.5
5.3
5.5
5.5

3.3
3.3
3.0
3.0

9.0
8.9
9.0
9.4

30.0
30.0
29.6
30.0

24.7
24.7
24.7
24.7
```
  • Loading branch information
giampaolo committed Aug 1, 2023
1 parent 252b6fd commit ef666ac
Show file tree
Hide file tree
Showing 3 changed files with 46 additions and 48 deletions.
14 changes: 12 additions & 2 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,20 @@

XXXX-XX-XX

- 2241_, [NetBSD]: can't compile On NetBSD 10.99.3/amd64. (patch by Thomas
Klausner)
**Enhancements**

- 1703_: `cpu_percent()`_ and `cpu_times_percent()`_ are now thread safe,
meaning they can be called from different threads and still return
meaningful and independent results. Before, if (say) 10 threads called
``cpu_percent(interval=None)`` at the same time, only 1 thread out of 10
would get the right result.
- 2266_: if `Process`_ class is passed a very high PID, raise `NoSuchProcess`_
instead of OverflowError. (patch by Xuehai Pan)

**Bug fixes**

- 2241_, [NetBSD]: can't compile On NetBSD 10.99.3/amd64. (patch by Thomas
Klausner)
- 2268_: ``bytes2human()`` utility function was unable to properly represent
negative values.
- 2252_: [Windows]: `psutil.disk_usage`_ fails on Python 3.12+. (patch by Matthieu Darbois)
Expand Down
9 changes: 9 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,10 @@ CPU
utilization as a percentage for each CPU.
First element of the list refers to first CPU, second element to second CPU
and so on. The order of the list is consistent across calls.
Internally this function maintains a global map (a dict) where each key is
the ID of the calling thread (`threading.get_ident`_). This means it can be
called from different threads, at different intervals, and still return
meaningful and independent results.

>>> import psutil
>>> # blocking
Expand All @@ -194,6 +198,8 @@ CPU
it will return a meaningless ``0.0`` value which you are supposed to
ignore.

.. versionchanged:: 5.9.6 function is now thread safe.

.. function:: cpu_times_percent(interval=None, percpu=False)

Same as :func:`cpu_percent()` but provides utilization percentages for each
Expand All @@ -212,6 +218,8 @@ CPU
.. versionchanged::
4.1.0 two new *interrupt* and *dpc* fields are returned on Windows.

.. versionchanged:: 5.9.6 function is now thread safe.

.. function:: cpu_count(logical=True)

Return the number of logical CPUs in the system (same as `os.cpu_count`_
Expand Down Expand Up @@ -3052,6 +3060,7 @@ Timeline
.. _`subprocess.Popen`: https://docs.python.org/3/library/subprocess.html#subprocess.Popen
.. _`temperatures.py`: https://github.com/giampaolo/psutil/blob/master/scripts/temperatures.py
.. _`TerminateProcess`: https://docs.microsoft.com/en-us/windows/desktop/api/processthreadsapi/nf-processthreadsapi-terminateprocess
.. _`threading.get_ident`: https://docs.python.org/3/library/threading.html#threading.get_ident
.. _`threading.Thread`: https://docs.python.org/3/library/threading.html#threading.Thread
.. _Tidelift security contact: https://tidelift.com/security
.. _Tidelift Subscription: https://tidelift.com/subscription/pkg/pypi-psutil?utm_source=pypi-psutil&utm_medium=referral&utm_campaign=readme
71 changes: 25 additions & 46 deletions psutil/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@
AF_LINK = _psplatform.AF_LINK

__author__ = "Giampaolo Rodola'"
__version__ = "5.9.5"
__version__ = "5.9.6"
version_info = tuple([int(num) for num in __version__.split('.')])

_timer = getattr(time, 'monotonic', time.time)
Expand Down Expand Up @@ -1629,16 +1629,18 @@ def cpu_times(percpu=False):


try:
_last_cpu_times = cpu_times()
_last_cpu_times = {threading.current_thread().ident: cpu_times()}
except Exception:
# Don't want to crash at import time.
_last_cpu_times = None
_last_cpu_times = {}

try:
_last_per_cpu_times = cpu_times(percpu=True)
_last_per_cpu_times = {
threading.current_thread().ident: cpu_times(percpu=True)
}
except Exception:
# Don't want to crash at import time.
_last_per_cpu_times = None
_last_per_cpu_times = {}


def _cpu_tot_time(times):
Expand Down Expand Up @@ -1732,8 +1734,7 @@ def cpu_percent(interval=None, percpu=False):
2.9
>>>
"""
global _last_cpu_times
global _last_per_cpu_times
tid = threading.current_thread().ident
blocking = interval is not None and interval > 0.0
if interval is not None and interval < 0:
raise ValueError("interval is not positive (got %r)" % interval)
Expand All @@ -1756,38 +1757,27 @@ def calculate(t1, t2):
t1 = cpu_times()
time.sleep(interval)
else:
t1 = _last_cpu_times
if t1 is None:
# Something bad happened at import time. We'll
# get a meaningful result on the next call. See:
# https://github.com/giampaolo/psutil/pull/715
t1 = cpu_times()
_last_cpu_times = cpu_times()
return calculate(t1, _last_cpu_times)
t1 = _last_cpu_times.get(tid) or cpu_times()
_last_cpu_times[tid] = cpu_times()
return calculate(t1, _last_cpu_times[tid])
# per-cpu usage
else:
ret = []
if blocking:
tot1 = cpu_times(percpu=True)
time.sleep(interval)
else:
tot1 = _last_per_cpu_times
if tot1 is None:
# Something bad happened at import time. We'll
# get a meaningful result on the next call. See:
# https://github.com/giampaolo/psutil/pull/715
tot1 = cpu_times(percpu=True)
_last_per_cpu_times = cpu_times(percpu=True)
for t1, t2 in zip(tot1, _last_per_cpu_times):
tot1 = _last_per_cpu_times.get(tid) or cpu_times(percpu=True)
_last_per_cpu_times[tid] = cpu_times(percpu=True)
for t1, t2 in zip(tot1, _last_per_cpu_times[tid]):
ret.append(calculate(t1, t2))
return ret


# Use separate global vars for cpu_times_percent() so that it's
# independent from cpu_percent() and they can both be used within
# the same program.
_last_cpu_times_2 = _last_cpu_times
_last_per_cpu_times_2 = _last_per_cpu_times
# Use a separate dict for cpu_times_percent(), so it's independent from
# cpu_percent() and they can both be used within the same program.
_last_cpu_times_2 = _last_cpu_times.copy()
_last_per_cpu_times_2 = _last_per_cpu_times.copy()


def cpu_times_percent(interval=None, percpu=False):
Expand All @@ -1803,8 +1793,7 @@ def cpu_times_percent(interval=None, percpu=False):
*interval* and *percpu* arguments have the same meaning as in
cpu_percent().
"""
global _last_cpu_times_2
global _last_per_cpu_times_2
tid = threading.current_thread().ident
blocking = interval is not None and interval > 0.0
if interval is not None and interval < 0:
raise ValueError("interval is not positive (got %r)" % interval)
Expand Down Expand Up @@ -1832,29 +1821,19 @@ def calculate(t1, t2):
t1 = cpu_times()
time.sleep(interval)
else:
t1 = _last_cpu_times_2
if t1 is None:
# Something bad happened at import time. We'll
# get a meaningful result on the next call. See:
# https://github.com/giampaolo/psutil/pull/715
t1 = cpu_times()
_last_cpu_times_2 = cpu_times()
return calculate(t1, _last_cpu_times_2)
t1 = _last_cpu_times_2.get(tid) or cpu_times()
_last_cpu_times_2[tid] = cpu_times()
return calculate(t1, _last_cpu_times_2[tid])
# per-cpu usage
else:
ret = []
if blocking:
tot1 = cpu_times(percpu=True)
time.sleep(interval)
else:
tot1 = _last_per_cpu_times_2
if tot1 is None:
# Something bad happened at import time. We'll
# get a meaningful result on the next call. See:
# https://github.com/giampaolo/psutil/pull/715
tot1 = cpu_times(percpu=True)
_last_per_cpu_times_2 = cpu_times(percpu=True)
for t1, t2 in zip(tot1, _last_per_cpu_times_2):
tot1 = _last_per_cpu_times_2.get(tid) or cpu_times(percpu=True)
_last_per_cpu_times_2[tid] = cpu_times(percpu=True)
for t1, t2 in zip(tot1, _last_per_cpu_times_2[tid]):
ret.append(calculate(t1, t2))
return ret

Expand Down

0 comments on commit ef666ac

Please sign in to comment.