Интересно, как ведут себя потоки, когда борются за GIL, или немного информации отсюда только для Python3.
Сразу оговорюсь, что использую
Ни для кого не секрет, что в Linux библиотека потоков реализует стандарт POSIX threads. Реализация потоков в CPython использует данные потоки, из-за чего управление ими полностью осуществляется операционной системой.
GIL в Python3 это булевская переменная
В главном цикле (см. файл
Переменная
В функции
Для изменения значения
Если поток пишет в файл или работает с сетью (или выполняет ещё какие-то I/O операции), то в таких случаях GIL отпускается. Так же он не используется в реализации некоторых библиотек, таких как Numpy.
Добавим логирование в функции
Можно было бы сохранять данные в массиве и выводить в файл в конце работы скрипта, но в данной реализации логи выводяться на стандартный поток вывода сразу во время выполнения.
По оси абсцисс обозначены просто тики, а не время. Стоит заметить, что нет возможности показать, в какое время потоки вообще работают, так как их запуском и остановкой управляет ОС.
Зелёные полоски — расстояние (в тиках) от тика, когда поток взял управления, до тика, когда — отдал. Красные полоски — расстояние от тика, когда поток установил
В примерах видно, что все отрезки примерно равны, что позволяет предположить, что все эти отрезки примерно по 5 миллисекунд. Из чего следует, что в Python3 невозможна ситуация, когда один поток надолго захватит управление, как это было в Python2. И не считая ситуации с «длительными» атомарными инструкциями, в общем, каждый поток через небольшие промежутки времени «с большой вероятностью» снова будет получать квант времени. Выходит, что выполнение хоть и не параллельное, но всё же.
Сразу оговорюсь, что использую
Ubuntu 16.04
c ядром 4.15.0-115-generic
, на машине стоит 4-х ядерный процессор Intel(R) Core(TM) i5-4200U CPU @ 1.60GHz
с 4 GB RAM.Теория
Ни для кого не секрет, что в Linux библиотека потоков реализует стандарт POSIX threads. Реализация потоков в CPython использует данные потоки, из-за чего управление ими полностью осуществляется операционной системой.
GIL в Python3 это булевская переменная
locked
, доступ к которой защищен мьютексом mutex
, и при изменении которой в false
, ОС «сигнализирует» какому-то потоку, который ожидает условную переменную cond
.Как это работает
В главном цикле (см. файл
ceval.c
) в зависимости от некоторых условий вызывается функция eval_frame_handle_pending
, в которой, если установлена пременная gil_drop_request
, текущий поток освобождает GIL, давая шанс другим потокам его захватить./* GIL drop request */
if (_Py_atomic_load_relaxed(&ceval2->gil_drop_request)) {
/* Give another thread a chance */
if (_PyThreadState_Swap(&runtime->gilstate, NULL) != tstate) {
Py_FatalError("tstate mix-up");
}
drop_gil(ceval, ceval2, tstate);
/* Other threads may run now */
take_gil(tstate);
if (_PyThreadState_Swap(&runtime->gilstate, tstate) != NULL) {
Py_FatalError("orphan tstate");
}
}
Переменная
gil_drop_request
устанавливается в функции take_gil
(см. файл ceval_gil.h
). Она устанавливается после ожидания потоком interval
миллисекунд условной переменной cond
. Этот приём не гарантирует, что через данный промежуток времени другой поток получит управление, так как некоторые атомарные операции могут выполняться гораздо дольше. С другой стороны, гарантируется, что после установки переменной gil_drop_request
, другой поток (кроме текущего) получит управление.while (_Py_atomic_load_relaxed(&gil->locked)) {
unsigned long saved_switchnum = gil->switch_number;
unsigned long interval = (gil->interval >= 1 ? gil->interval : 1);
int timed_out = 0;
COND_TIMED_WAIT(gil->cond, gil->mutex, interval, timed_out);
/* If we timed out and no switch occurred in the meantime, it is time
to ask the GIL-holding thread to drop it. */
if (timed_out &&
_Py_atomic_load_relaxed(&gil->locked) &&
gil->switch_number == saved_switchnum)
{
if (tstate_must_exit(tstate)) {
MUTEX_UNLOCK(gil->mutex);
PyThread_exit_thread();
}
assert(is_tstate_valid(tstate));
SET_GIL_DROP_REQUEST(interp);
}
}
В функции
drop_gil
, после установки переменной locked
в false
, «сигнализируется» условная переменная cond
.MUTEX_LOCK(gil->mutex);
_Py_ANNOTATE_RWLOCK_RELEASED(&gil->locked, /*is_write=*/1);
_Py_atomic_store_relaxed(&gil->locked, 0);
COND_SIGNAL(gil->cond);
MUTEX_UNLOCK(gil->mutex);
Для изменения значения
interval
, можно воспользоваться функцией sys.setswitchinterval
. По умолчанию это значение равно 5 миллисекундам (можно получить через sys.getswitchinterval
).Если поток пишет в файл или работает с сетью (или выполняет ещё какие-то I/O операции), то в таких случаях GIL отпускается. Так же он не используется в реализации некоторых библиотек, таких как Numpy.
Визуализация
Реализация
Добавим логирование в функции
take_gil
и drop_gil
и счётчик ATOMIC_COUNT
.// В ceval.c
volatile int ATOMIC_COUNT = 0;
...
main_loop:
ATOMIC_COUNT++;
for (;;) {
...
// В ceval_gil.h
extern volatile int ATOMIC_COUNT;
...
static void
take_gil(PyThreadState *tstate)
{
...
while (_Py_atomic_load_relaxed(&gil->locked)) {
unsigned long saved_switchnum = gil->switch_number;
unsigned long interval = (gil->interval >= 1 ? gil->interval : 1);
int timed_out = 0;
COND_TIMED_WAIT(gil->cond, gil->mutex, interval, timed_out);
fprintf(stdout, "busy gil: %d %d %d\n", pthread_self(), ATOMIC_COUNT, interval);
...
}
...
fprintf(stdout, "take gil: %d %d\n", pthread_self(), ATOMIC_COUNT);
}
static void
drop_gil(struct _ceval_runtime_state *ceval, struct _ceval_state *ceval2,
PyThreadState *tstate)
{
...
fprintf(stdout, "drop gil: %d %d\n", pthread_self(), ATOMIC_COUNT);
}
Можно было бы сохранять данные в массиве и выводить в файл в конце работы скрипта, но в данной реализации логи выводяться на стандартный поток вывода сразу во время выполнения.
По оси абсцисс обозначены просто тики, а не время. Стоит заметить, что нет возможности показать, в какое время потоки вообще работают, так как их запуском и остановкой управляет ОС.
Зелёные полоски — расстояние (в тиках) от тика, когда поток взял управления, до тика, когда — отдал. Красные полоски — расстояние от тика, когда поток установил
gil_drop_request
, до тика, когда либо установил эту переменную повторно, либо взял управление.
Пример сборки
git clone https://github.com/python/cpython.git
mkdir debug_python
cd debug_python
../cpython/configure
make
cd ..
Примеры
- Атомарные операции могут выполняться долго.
Запустим:
debug_python/python main.py --type="cpu-bound" 1>logs python drawing.py
На рисунке ниже в узких полосках время выполнения больше 5 миллисекунд, из-за чего успевает выполнится только один тик (сортировка массива).
- Не обязательно тот поток, что установил
gil_drop_request
, получит управление.
Запускаем аналогично.
В примере ниже главный поток ждёт 4 раза по около 5 миллисекунд, прежде чем получит управление.
- Попробуем установить значение
interval
в 30 миллисекунд.
Запускаем аналогично.
Код из main.py
import sys
import threading
import random
import argparse
text = """
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque id mi tortor. Pellentesque habitant morbi
tristique senectus et netus et malesuada fames ac turpis egestas. Mauris arcu neque, tempor interdum magna non,
fringilla maximus ex. Proin a mollis elit. Nunc lacinia mollis sem, eget sodales ligula vulputate at. In euismod
elit vel mi suscipit, in pellentesque velit tempor. Nullam eleifend ornare risus ac ultricies. Nam interdum velit
sit amet eros dapibus euismod. Proin non orci imperdiet, interdum velit in, cursus justo. Nullam fringilla, tortor
quis sollicitudin pretium, erat felis porta odio, dictum sodales massa nisi id magna. Integer vitae ipsum ac lectus
imperdiet tristique ac a nibh. Interdum et malesuada fames ac ante ipsum primis in faucibus. Maecenas suscipit id mi
ac eleifend. Interdum et malesuada fames ac ante ipsum primis in faucibus.
"""
def func_cpu_bound(n):
for _ in range(n):
[random.randint(1, 1000000) for _ in range(10000)].sort()
def func_io_bound(n):
while n > 0:
with open(f"{threading.get_ident()}", "w") as f:
f.write(text)
n -= 1
def run_threads(type):
if type == "cpu-bound":
t1 = threading.Thread(target=func_cpu_bound, args=(10000000,))
t2 = threading.Thread(target=func_cpu_bound, args=(10000000,))
elif type == "io-bound":
t1 = threading.Thread(target=func_io_bound, args=(50,))
t2 = threading.Thread(target=func_io_bound, args=(50,))
t1.start()
t2.start()
t1.join()
t2.join()
def run_without_threads(type):
if type == "cpu-bound":
func_cpu_bound(10000000)
func_cpu_bound(10000000)
elif type == "io-bound":
func_io_bound(50)
func_io_bound(50)
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument("-wt", "--without-threads", dest="without_threads", action="store_true")
parser.add_argument("-t", "--type", dest="type", choices=["cpu-bound", "io-bound"])
args = parser.parse_args()
return args
def main():
args = parse_args()
if args.without_threads:
run_without_threads(args.type)
else:
run_threads(args.type)
if __name__ == "__main__":
main()
Код из drawing.py
from collections import defaultdict
import matplotlib.pyplot as plt
import matplotlib.patches as patches
with open("logs", "r") as f:
fig, ax = plt.subplots(1, figsize=(20, 10))
lines = defaultdict(list)
for line in f:
if ":" not in line:
continue
name, tokens = line.split(":")
name = name.strip()
if "gil" in name:
ident, num_op, *other = tokens.strip().split()
ident = int(ident)
num_op = int(num_op)
lines[ident].append((num_op, name))
def get_color(name):
if name == "take gil":
return "g"
elif name == "busy gil":
return "r"
elif name == "drop gil":
return "y"
for idx, (key, items) in enumerate(lines.items()):
for i in range(len(items)):
if items[i][1] in ["take gil", "busy gil"]:
if i + 1 < len(items):
rect = patches.Rectangle((items[i][0], idx), items[i + 1][0] - items[i][0], 1, color=get_color(items[i][1]), fill=True)
ax.add_patch(rect)
else:
rect = patches.Rectangle((items[i][0], idx), num_op - items[i][0], 1, color=get_color(items[i][1]), fill=True)
ax.add_patch(rect)
plt.xlim(9950, num_op)
plt.ylim(0, 3)
plt.show()
Заключение
В примерах видно, что все отрезки примерно равны, что позволяет предположить, что все эти отрезки примерно по 5 миллисекунд. Из чего следует, что в Python3 невозможна ситуация, когда один поток надолго захватит управление, как это было в Python2. И не считая ситуации с «длительными» атомарными инструкциями, в общем, каждый поток через небольшие промежутки времени «с большой вероятностью» снова будет получать квант времени. Выходит, что выполнение хоть и не параллельное, но всё же.