Мы работаем над базой данных EdgeDB и в настоящее время портируем с Python на Rust существенную часть кода, отвечающего за сетевой ввод/вывод. В процессе работы мы узнали много всего интересного.
Отказ, который происходит только на ARM64
Мы разрабатывали для EdgeDB новую возможность выборки HTTP. В качестве клиентской HTTP-библиотеки мы использовали reqwest. Всё шло гладко: фича работала на локальной машине, проходила тесты на сборочных агентах x86_64
и казалась стабильной. Но мы заметили кое-что странное: тесты то и дело не проходили на сборочных агентах для ARM64.
На первый взгляд ситуация напоминала взаимную блокировку. Сборочный агент запускался, зависал на неопределённое время, после чего время на выполнение сборочного задания истекало. В логах не отображалось никаких ошибок — просто тест крутился впустую. Затем, спустя несколько часов, задание завершалось отказом и выдавало ошибку «время истекло».
Вот как выглядел вывод системы непрерывной интеграции:
Current runner version: '2.321.0'
Runner name: '<instance-id>'
Runner group name: 'Default'
(... 6 hrs of logs ...)
still running:
test_immediate_connection_drop_streaming (pid=451) for 19874.78s
still running:
test_immediate_connection_drop_streaming (pid=451) for 19875.78s
still running:
test_immediate_connection_drop_streaming (pid=451) for 19876.78s
still running:
test_immediate_connection_drop_streaming (pid=451) for 19877.78s
Shutting down test cluster...
Тут мало что происходит. Создаётся впечатление, как будто из-за взаимной блокировки асинхронная задача не может работать и с нашей точки зрения заблокируется. Но оказалось, что мы ошибались.
Наши исходные соображения
Почему проблема в ARM64? Сначала мы не могли найти этому объяснения. Среди первого мы предположили, что существуют различия между моделями памяти, применяемыми в Intel и ARM64. В Intel принята достаточно строгая модель памяти. Притом, что определённые необычные варианты поведения всё-таки возможны, при записях в память поддерживается общий порядок, который соблюдается во всех процессорах Intel ([1], [2], [3]). В ARM действует значительно менее строгая модель памяти [4], в которой (среди прочего) порядок следования записей может отличаться с точки зрения разных потоков.
Салли написал по этому поводу квалификационную работу на Ph.D. [5], поэтому мы и пригласили его посмотреть, что здесь происходит.
Отладка на машине для непрерывной интеграции
Наши машины для ночных сборок методом непрерывной интеграции работают на серверах Amazon AWS. Преимущество таких серверов в том, что на них можно смоделировать настоящего неконтейнеризованного пользователя с правами администратора. Притом, что можно подключиться к агентам github через ssh [6], хорошо бы иметь возможность подключаться в качестве полноценного администратора, чтобы получать доступ к dmesg и другим системным логам.
Чтобы выяснить, что происходит, мы (Салли и Мэтт) решили подключиться непосредственно к агенту для ARM64 и посмотреть, что происходит под капотом.
Сначала мы зашли через SSH на машину для непрерывной интеграции, попытались найти этот подвисший процесс и подключиться к нему:
$ aws ssm start-session --region us-west-2 --target i-<instance-id>
$ ps aux | grep "451"
<no output>
Oказалось, это верно! Мы запускаем сборку в контейнере Docker, а у него — собственное пространство имён для процессов:
$ sudo docker exec -it <container-id> /bin/sh
# ps aux | grep "451"
<no output>
Постойте-ка. Здесь этого зависшего процесса тоже нет.
Значит, это была не взаимная блокировка — процесс аварийно завершился.
Как выясняется, наш тестовый агент просто не смог этого зафиксировать. Но ладно, эту ошибку мы исправим как-нибудь потом. Можем посмотреть, остался ли от процесса дамп памяти. Поскольку контейнер Docker — это просто отдельное пространство имён для процессов, дамп памяти передаётся на хост с Docker. Можно попытаться докопаться до него извне контейнера при помощи journalctl
:
$ sudo journalctl
systemd-coredump: Process 59530 (python3) of user 1000 dumped core.
Stack trace of thread <tid>:
...
Ага! Нашли его. Как и ожидалось, дамп для этого процесса лежит в /var/lib/systemd/coredump/
. Обратите внимание: именно из-за разницы в пространствах имён здесь наблюдаются разные идентификаторы процессов. Вне контейнера мы увидим pid 59530, а внутри — 1000.
Мы загрузили дамп памяти в gdb, желая проверить, что же произошло. К сожалению, в ответ нам высыпался ворох ошибок:
$ gdb
(gdb) core-file core.python3.1000.<...>.59530.<...>
warning: Can't open file /lib64/libnss_files-2.17.so during file-backed mapping note processing
warning: Can't open file /lib64/librt-2.17.so during file-backed mapping note processing
warning: Can't open file /lib64/libc-2.17.so during file-backed mapping note processing
warning: Can't open file /lib64/libm-2.17.so during file-backed mapping note processing
warning: Can't open file /lib64/libutil-2.17.so during file-backed mapping note processing
... etc ...
(gdb) bt
#0 0x0000ffff805a3e90 in ?? ()
#1 0x0000ffff806a7000 in ?? ()
Backtrace stopped: not enough registers or memory available to unwind further
Ладно. Это не помогло. Мы не располагаем необходимыми файлами вне контейнера, а наши контейнеры весьма минималистичные, поэтому в них не так просто установить gdb
.
Вместо этого нам потребуется скопировать из контейнера все релевантные библиотеки и сообщить gdb
, где находятся файлы .so
:
# mkdir /container
# docker cp <instance>:/lib /container
# docker cp <instance>:/usr /container
... etc ...
$ gdb
(gdb) set solib-absolute-prefix /container
(gdb) file /container/edgedb/bin/python3
Reading symbols from /container/edgedb/bin/python3...
(No debugging symbols found in /container/edgedb/bin/python3)
(gdb) core-file core.python3.1000.<...>.59530.<...>
(gdb) bt
#0 0x0000ffff805a3e90 in getenv () from /container/lib64/libc.so.6
#1 0x0000ffff8059c174 in __dcigettext () from /container/lib64/libc.so.6
Гораздо лучше!
Но в нашем новом HTTP-коде обратная трассировка выявила не отказ, а нечто неожиданное:
(gdb) bt
#0 0x0000ffff805a3e90 in getenv () from /container/lib64/libc.so.6
#1 0x0000ffff8059c174 in __dcigettext () from /container/lib64/libc.so.6
#2 0x0000ffff805f263c in strerror_r () from /container/lib64/libc.so.6
#3 0x0000ffff805f254c in strerror () from /container/lib64/libc.so.6
#4 0x00000000005bb76c in PyErr_SetFromErrnoWithFilenameObjects ()
#5 0x00000000004e4c14 in ?? ()
#6 0x000000000049f66c in PyObject_VectorcallMethod ()
#7 0x00000000005d21e4 in ?? ()
#8 0x00000000005d213c in ?? ()
#9 0x00000000005d1ed4 in ?? ()
#10 0x00000000004985ec in _PyObject_MakeTpCall ()
#11 0x00000000004a7734 in _PyEval_EvalFrameDefault ()
#12 0x000000000049ccb4 in _PyObject_FastCallDictTstate ()
#13 0x00000000004ebce8 in ?? ()
#14 0x00000000004985ec in _PyObject_MakeTpCall ()
#15 0x00000000004a7734 in _PyEval_EvalFrameDefault ()
#16 0x00000000005bee10 in ?? ()
#17 0x0000ffff7ee1f5dc in ?? () from /container/.../_asyncio.cpython-312-aarch64-linux-gnu.so
#18 0x0000ffff7ee1fd94 in ?? () from /container/.../_asyncio.cpython-312-aarch64-linux-gnu.so
Мы дизассемблировали аварийно завершающуюся функцию getenv
. Поскольку мы знали, что для сборки контейнеров применяется GLIBC 2.17, мы также отыскали соответствующий исходный код getenv
, чтобы его можно было проследить [7]:
/* ... примечание: переформатировано для краткости ... */
char * getenv (const char *name) {
size_t len = strlen (name);
char **ep;
uint16_t name_start;
if (__environ == NULL || name[0] == '\0')
return NULL;
if (name[1] == '\0') {
/* Имя переменной состоит всего из одного символа. Следовательно, первые два символа в записи окружения — этот символ и символ '='. */
name_start = ('=' << 8) | *(const unsigned char *) name;
for (ep = __environ; *ep != NULL; ++ep) {
uint16_t ep_start = (((unsigned char *) *ep)[0]
| (((unsigned char *) *ep)[1] << 8));
if (name_start == ep_start)
return &(*ep)[2];
}
} else {
name_start = (((const unsigned char *) name)[0]
| (((const unsigned char *) name)[1] << 8));
len -= 2;
name += 2;
for (ep = __environ; *ep != NULL; ++ep) {
uint16_t ep_start = (((unsigned char *) *ep)[0]
| (((unsigned char *) *ep)[1] << 8));
if (name_start == ep_start && !strncmp (*ep + 2, name, len)
&& (*ep)[len + 2] == '=')
return &(*ep)[len + 3];
}
}
return NULL;
}
(gdb) disassemble getenv
Dump of assembler code for function getenv:
0x0000ffff805a3de4 <+0>: stp x29, x30, [sp, #-64]!
0x0000ffff805a3de8 <+4>: mov x29, sp
0x0000ffff805a3dec <+8>: stp x19, x20, [sp, #16]
0x0000ffff805a3df0 <+12>: stp x21, x22, [sp, #32]
0x0000ffff805a3df4 <+16>: stp x23, x24, [sp, #48]
0x0000ffff805a3df8 <+20>: mov x22, x0
0x0000ffff805a3dfc <+24>: bl 0xffff805f2784 <strlen>
0x0000ffff805a3e00 <+28>: mov x24, x0
0x0000ffff805a3e04 <+32>: adrp x0, 0xffff806eb000
0x0000ffff805a3e08 <+36>: ldr x0, [x0, #3704]
0x0000ffff805a3e0c <+40>: ldr x20, [x0]
0x0000ffff805a3e10 <+44>: cbz x20, 0xffff805a3ed8 <getenv+244>
0x0000ffff805a3e14 <+48>: ldrb w1, [x22]
0x0000ffff805a3e18 <+52>: cbz w1, 0xffff805a3ed0 <getenv+236>
0x0000ffff805a3e1c <+56>: ldrb w21, [x22, #1]
0x0000ffff805a3e20 <+60>: ldr x19, [x20]
0x0000ffff805a3e24 <+64>: cbnz w21, 0xffff805a3e70 <getenv+140>
0x0000ffff805a3e28 <+68>: mov w21, #0x3d00 // #15616
0x0000ffff805a3e2c <+72>: orr w21, w1, w21
0x0000ffff805a3e30 <+76>: cbnz x19, 0xffff805a3e40 <getenv+92>
0x0000ffff805a3e34 <+80>: b 0xffff805a3e58 <getenv+116>
0x0000ffff805a3e38 <+84>: ldr x19, [x20, #8]!
0x0000ffff805a3e3c <+88>: cbz x19, 0xffff805a3e58 <getenv+116>
0x0000ffff805a3e40 <+92>: ldrb w1, [x19, #1]
0x0000ffff805a3e44 <+96>: ldrb w0, [x19]
0x0000ffff805a3e48 <+100>: orr w0, w0, w1, lsl #8
0x0000ffff805a3e4c <+104>: cmp w21, w0
0x0000ffff805a3e50 <+108>: b.ne 0xffff805a3e38 <getenv+84> // b.any
0x0000ffff805a3e54 <+112>: add x19, x19, #0x2
0x0000ffff805a3e58 <+116>: mov x0, x19
0x0000ffff805a3e5c <+120>: ldp x21, x22, [sp, #32]
0x0000ffff805a3e60 <+124>: ldp x19, x20, [sp, #16]
0x0000ffff805a3e64 <+128>: ldp x23, x24, [sp, #48]
0x0000ffff805a3e68 <+132>: ldp x29, x30, [sp], #64
0x0000ffff805a3e6c <+136>: ret
0x0000ffff805a3e70 <+140>: orr w21, w1, w21, lsl #8
0x0000ffff805a3e74 <+144>: sxth w21, w21
0x0000ffff805a3e78 <+148>: sub x23, x24, #0x2
0x0000ffff805a3e7c <+152>: add x22, x22, #0x2
0x0000ffff805a3e80 <+156>: cbnz x19, 0xffff805a3e90 <getenv+172>
0x0000ffff805a3e84 <+160>: b 0xffff805a3e58 <getenv+116>
0x0000ffff805a3e88 <+164>: ldr x19, [x20, #8]!
0x0000ffff805a3e8c <+168>: cbz x19, 0xffff805a3e58 <getenv+116>
=> 0x0000ffff805a3e90 <+172>: ldrb w4, [x19, #1]
0x0000ffff805a3e94 <+176>: ldrb w3, [x19]
0x0000ffff805a3e98 <+180>: orr w3, w3, w4, lsl #8
0x0000ffff805a3e9c <+184>: cmp w21, w3, sxth
0x0000ffff805a3ea0 <+188>: b.ne 0xffff805a3e88 <getenv+164> // b.any
0x0000ffff805a3ea4 <+192>: add x0, x19, #0x2
0x0000ffff805a3ea8 <+196>: mov x1, x22
0x0000ffff805a3eac <+200>: mov x2, x23
0x0000ffff805a3eb0 <+204>: bl 0xffff805f2a44 <strncmp>
0x0000ffff805a3eb4 <+208>: cbnz w0, 0xffff805a3e88 <getenv+164>
0x0000ffff805a3eb8 <+212>: ldrb w0, [x19, x24]
0x0000ffff805a3ebc <+216>: cmp w0, #0x3d
0x0000ffff805a3ec0 <+220>: b.ne 0xffff805a3e88 <getenv+164> // b.any
0x0000ffff805a3ec4 <+224>: add x24, x24, #0x1
0x0000ffff805a3ec8 <+228>: add x19, x19, x24
0x0000ffff805a3ecc <+232>: b 0xffff805a3e58 <getenv+116>
0x0000ffff805a3ed0 <+236>: mov x19, #0x0 // #0
0x0000ffff805a3ed4 <+240>: b 0xffff805a3e58 <getenv+116>
0x0000ffff805a3ed8 <+244>: mov x19, x20
0x0000ffff805a3edc <+248>: b 0xffff805a3e58 <getenv+116>
0x0000ffff805a3eb8 <+212>: ldrb w0, [x19, x24]
0x0000ffff805a3ebc <+216>: cmp w0, #0x3d
0x0000ffff805a3ec0 <+220>: b.ne 0xffff805a3e88 <getenv+164> // b.any
0x0000ffff805a3ec4 <+224>: add x24, x24, #0x1
0x0000ffff805a3ec8 <+228>: add x19, x19, x24
0x0000ffff805a3ecc <+232>: b 0xffff805a3e58 <getenv+116>
0x0000ffff805a3ed0 <+236>: mov x19, #0x0 // #0
0x0000ffff805a3ed4 <+240>: b 0xffff805a3e58 <getenv+116>
0x0000ffff805a3ed8 <+244>: mov x19, x20
0x0000ffff805a3edc <+248>: b 0xffff805a3e58 <getenv+116>
End of assembler dump.
Уф, значит, аварийное завершение происходит во врем�� загрузки байта и в ходе поиска интересующей нас переменной окружения.
Можно вывести дамп актуального состояния всех регистров:
(gdb) info reg
...
x19 0x220 544
x20 0x248b5000 613109760
...
sp 0xffffddd93c80 0xffffddd93c80
pc 0xffff805a3e90 0xffff805a3e90
Итак, getenv
отказывала, пытаясь загрузить данные из недействительного региона памяти (0x220 – очевидно, такого значения быть не может). Но как?
Что же происходило?
Поначалу нас это озадачило. Отказ происходил глубоко в libc
. Мы подозревали, дело может быть в том, что переменная окружения просто повреждена — учитывая, что происходил вызов getenv, но для дальнейшего расследования нам не хватало информации.
Мы принялись проверять блок окружения при помощи gdb
.
Напомню: в соответствии со стандартом POSIX [8] environ
определяется как char **
и фактически представляет собой список указателей на строки окружения, а конец списка отмечается как NULL
-указатель.
(gdb) x/s ((char**) environ)[0]
0xffffddd95e6a: "GITHUB_STATE=/github/file_commands/save_state_0e5b7bd6-..."
...
(gdb) x/s ((char**) environ)[66]
0xffff6401f0b0: "SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"
(gdb) x/s ((char**) environ)[67]
0xffff6401f8d0: "SSL_CERT_DIR=/etc/ssl/certs"
(gdb) x/s ((char**) environ)[68]
0x0: <error: Cannot access memory at address 0x0>
<etc>
Но ведь это нонсенс — мы наблюдаем решительно невозможную операцию загрузку из пространства памяти, а окружение ведёт себя так, как будто эта операция совершенно валидна и непротиворечива. Кстати, а почему мы вообще вызываем здесь getenv
?
А потом пожаловал Юрий и скинул в комментарии ссылку на один старый пост:
<yury> Кажется, что некоторые операции, относящиеся к вводу/выводу, выливаются в ошибки, а Python пытается при помощи
PyErr_SetFromErrnoWithFilenameObjects
сконструировать изerrno
исключение
<yury> По-видимому, эта функция отмечается на
gettext
(заготовка для трансляции?)И дальше идёт в
getenv
<yury> Возможно, именно поэтому
getenv
не потокобезопаснаhttps://rachelbythebay.com/w/2017/01/30/env/
Вот где собака зарыта: setenv и getenv
Функцию setenv небезопасно вызывать в многопоточной среде. Зачастую это представляет проблему, и данный феномен то и дело переоткрывается, когда наш брат-разработчик сталкивается с таинственными отказами функции getenv
из libc
[9], [10], [11], [12].
Казалось вероятным, что причина именно в этом, но, учитывая явную нехватку символов, мы не могли понять, каков вклад каждого из эксплуатируемых нами потоков в этот отказ.
Почитав дизассемблированный код и соотнеся его с кодом на C, мы определили, что регистр x20
соответствует переменной ep
. Это указатель, используемый для обхода массива environ
. Но оказалось, что x20
соответствует 0x248b5000, а environ
— 0x28655750, почти на 60 мегабайт позже в памяти.
Поскольку x20
— это указатель, применяемый для считывания старого окружения, можно осмотреть окружающую область памяти и проверить, не сохранились ли там какие-то крупицы интересующей нас информации — а потом сравнить её с актуальным состоянием environ
.
(gdb) x/100g (char**)environ
0x28655750: 0x0000ffffddd95e6a 0x0000ffffddd95ebd
...
0x28655930: 0x0000ffffddd96f34 0x0000ffffddd96f6e
0x28655940: 0x0000ffffddd96fa5 0x0000ffffddd96fc3
0x28655950: 0x0000000024c1f710 0x0000000025213a70
0x28655960: 0x0000ffff6401f0b0 0x0000ffff6401f8d0
0x28655970: 0x0000000000000000 0x0000000000003401
(gdb) x/20g $x20-40
0x248b4fd8: 0x0000ffffddd96f6e 0x0000ffffddd96fa5
0x248b4fe8: 0x0000ffffddd96fc3 0x0000000024c1f710
0x248b4ff8: 0x0000000025213a70 0x0000000000000220
0x248b5008: 0x0000000000000020 0x0000ffff7f5192a8
0x248b5018: 0x0000000000000000 0x000000000a000150
0x248b5028: 0x0000000000000031 0x0000ffff7f5192b8
0x248b5038: 0x0000000000000000 0x000000000a0001c6
0x248b5048: 0x000000000094af78 0x0000000000000030
0x248b5058: 0x0000000000000041 0x0000000000000000
0x248b5068: 0x0000000000000000 0x0000000000000000
Интересно! Значения указателей в двух областях памяти очень похожи! А где они начинают различаться? Это последние записи по адресам 0x0000ffff6401f0b0
и 0x0000ffff6401f8d0
: они соответствуют SSL_CERT_FILE=...
и SSL_CERT_DIR=...
!
Всё это явно подсказывает, что мы верно догадывались насчёт гонки данных, и другой поток перемещал environ в рамках setenv
! При рассмотрении setenv
казалось, что блок окружения заключён в слишком тесной области памяти и, возможно, имела место повторная аллокация, чтобы в памяти уместились новые переменные [13].
При этом мы всё ещё не выяснили, какой код вызывает setenv. Казалось возможным, что обвал происходит из-за OpenSSL и/или какой-то другой зависимости reqwest
, относящейся к TLS (rust-native-tls
), но как?
Подключение к openssl_probe
Погуглив эти переменные окружения в связке с rust-native-tls
, мы выудили старинную проблему: [14]. А в одном из комментариев скрывалось следующее:
Не уверен насчёт
openssl
. Создаётся впечатление, как будто сейчас он загружает системные сертификаты при помощи библиотеки openssl-probe, и при этом устанавливаются переменные окруженияSSL_CERT_FILE
иSSL_CERT_DIR
, а после этого в дело вступаетSslConnector::builder
, вызывающийctx.set_default_verify_paths
, который и заглядывает в эти переменные окружения.Учитывая, что переменные окружения устанавливаются глобально и всего один раз, максимально разумно было бы, пожалуй, просто пытаться очищать хранилище, когда вся работа закончена. По-видимому, у меня на локальной машине это срабатывало.
Интересно. Итак, openssl-probe
устанавливает эти переменные. Причём, само собой, под Linux мы пользуемся серверным интерфейсом rust-native-tls openssl
, из которого идут вызовы в эти функции!
Вот совершенно безобидные на вид строки из библиотеки openssl-probe
, в которых просто не стоит unsafe
[15]:
pub fn try_init_ssl_cert_env_vars() -> bool {
let ProbeResult { cert_file, cert_dir } = probe();
// мы не собираемся затирать имеющиеся переменные окружения,
// поскольку, если они валидные, то probe() вернёт их
// в неизменном виде
if let Some(path) = &cert_file {
env::set_var(ENV_CERT_FILE, path);
}
if let Some(path) = &cert_dir {
env::set_var(ENV_CERT_DIR, path);
}
cert_file.is_some() || cert_dir.is_some()
}
Именно так мы и нарвались на аварийное завершение. Оно происходило из-за кода на Rust, в котором отсутствуют unsafe
, и который патологически взаимодействует с libc
где-то в другой точке программы.
В качестве отступления: что же такое RISC?
Притом, что оба мы имеем опыт обратной разработки, Мэтт был растренирован в работе с aarch64, a Салли этого вообще не умел. Поэтому мы некоторое время вместе потупили, рассматривая один из главных циклов в ассемблере. По-видимому, в коде был расчёт на то, что значение x20
изменится, и этот регистр наиболее явственно просился на представление ep, но он, по-видимому, не фигурировал в левой части ни одной из инструкций.
И тут мы заметили любопытный восклицательный знак:
0x0000ffff805a3e88 <+164>: ldr x19, [x20, #8]!
Оказывается, это «пре-индексный» режим адресации, действующий по принципу x19 = *(x20 + 8); x20 = x20 + 8
[16].
Такой маленький миленький оператор, но мы достаточно повидали и помним, что нам рассказывали: режимы адресации с автоматическим инкрементом — это наследие старинных машин CISC, таких как VAX. Без них обходились даже более современные машины из категории CISC, например, x86, и уж конечно элегантные и простые архитектуры RISC. Полагаю, как раз тот случай, когда всё новое — хорошо забытое старое.
(Апдейт: ну, на самом деле, не такое уж новое. В ARM это существует с самого начала, RISC, думаю, опоздал примерно на неделю).
Так почему же только на ARM64 и на Linux?
Поскольку данная авария возникает из-за realloc
, перемещающей память, а сама эта ситуация провоцируется setenv
, и всё это происходит в тот самый момент, когда другой поток вызывает getenv. Этот пазл складывается сразу из множества кусочков. Переменных окружения должно быть ровно столько, чтобы потребовалась повторная аллокация. Отказ ввода/вывода — отдельная проблема, но он подхватывается asyncio
, и здесь требуется вызвать getenv, которая, в свою очередь, извлечёт переменную окружения LANGUAGE в самый неподходящий момент.
Значение 0x220 подозрительно напоминает размер старого окружения, выраженный в 64-разрядных словах (0x220 / 8 = 68), и этим значением затирался завершающий NULL
блока окружения ещё до того, как он был перемещён. Вероятно, это делалось, чтобы указать функц��и malloc размер свободного блока. Но при этом программа как раз минировалась опасным невалидным указателем, и на нём подрывались жертвы, испытывавшие на себе использование после высвобождения.
Учитывая все эти предусловия, нам ещё повезло, что удалось в основном воспроизвести данный отказ на отдельно взятой платформе.
Исправление
В итоге мы решили переезжать с серверного интерфейса rust-native-tls/openssl
от reqwest
на rustls
под Linux. Исходно мы полагали, что, пользуясь нативным TLS-бекэндом, мы обойдёмся без одновременного применения двух TLS-движков в процессе портирования кода с Python на Rust. Столкнувшись с этой проблемой, мы решили, что в краткосрочной перспективе работать одновременно с двумя движками вполне нормально.
Был и другой вариант: на первый раз вызывать try_init_ssl_cert_env_vars
, удерживая в Python глобальную блокировку интерпретатора (та самая GIL, которой вас пугали). В Rust предусмотрена внутренняя блокировка, предотвращающая гонки между фрагментами кода Rust, одновременно читающими и пишущими окружение. Но эта блокировка не мешает коду из других языков напрямую использовать libc
. Удерживая GIL, мы, как минимум, страховались от гонки с нашими Python-потоками.
В проекте Rust эта проблема уже зафиксирована, и в версии 2024 планировалось сделать небезопасными функции, устанавливающие окружение [17]. В проекте glibc
также (совсем недавно) усилили потокобезопасность в getenv
, избегая использования realloc и просачивания в более старые окружения [18].