Смержил патч от akovalenko, использующий TLS-слот 63. С ним и возникла проблема — каким-то образом забыл смержить следующий его коммит, исправляющий опечатку; из-за этого (и из-за нехватки времени) не мог понять, в чем дело. Вообще, надо быть внимательнее и не допускать таких оплошностей.

После чего уже смержил SBCL-1.0.45 и выложил сборку (содержащую часть патчей от akovalenko) на https://sites.google.com/site/dmitryvksite/sbcl-distr/sbcl-1.0.45-threads.msi.

Обновил sbcl-windows-threads до SBCL-1.0.44, инсталлятор выложил на https://sites.google.com/site/dmitryvksite/sbcl-distr/sbcl-1.0.44-threads-1.msi.

The updated version of implementation notes is published on http://dmitryvk.github.com/sbcl-win32-threads/implementation-notes.html

Suspend

Thread suspension is implemented as safepoints. Safepoint is implemented with read of memory location ('GC poll address' which is located in 'GC poll page').

At first phase, the 'master' thread unmaps the GC poll page. After this, other threads will at some time get page faults. There are several issues that must be dealt with:

1) The reaction to unmapping is not immediate - thread must reach the safepoint

2) Some threads will not reach safepoint soon (if thread is executing foreign code or a blocking system call)

3) Even if a thread has reached a safepoint, it does not mean that GC can start. The thread may be inside WITHOUT-GCING section, for example. In this case, thread may not be resumed with GC poll page unmapped.

We can draw some conclusions:

1) Every thread that can reach safepoint (if it's not in foreign code or in blocking syscall) must reach it before GC can proceed.

1.a) Every thread can can not reach safepoint must not interfere with GC if it will suddenly return to lisp code

2) After all threads have reached safepoint, we must wait for all threads to be ready for GC.

This implies two-phase suspend.

Phase 1:

1) GC poll page is remapped as unreadable

2) master thread checks each thread: if it's running lisp code, wait until it reaches a safepoint. Thread is considered to reach a safepoint when it's state is STATE_SUSPENDED_BRIEFLY.

Phase 2:

1) GC poll page is mapped again so that threads can run until they are ready for gc

2) Master thread waits for every thread to be ready for GC. This is achieved by waiting for state of every thread to become STATE_SUSPENDED (except for threads that are ready for GC)

Thread is ready for GC if:

1) thread_state(thread) == STATE_SUSPENDED

2) thread_is running foreign code and it is not inside WITHOUT-GCING or WITHOUT-INTERRUPTS and blockable signals are unblocked

For this, thread-local variable *GC-SAFE* is introduced - it tracks the current readiness for GC of a thread. It is guaranteed that when *GC-SAFE* changes from NIL to T, thread checks if GC is in progress and enters suspended state.

Thread interruption is similar, but we don't need to wait for all threads to reach a safepoint - it is only necessary for interrupted thread to reach safepoint.

Phase 1:

1) GC poll page is remapped as unreadable

2) master thread checks interrupted thread: if it's running lisp code, wait until it reaches a safepoint. Thread is considered to reach a safepoint when it's state is STATE_SUSPENDED_BRIEFLY.

Phase 2:

1) GC poll page is mapped

2) All threads that have reached a safepoint are released

Safepoint code

Safepoint code is called to check whether thread has something to do related to SBCL internal working. It is called:

1) When thread reaches a safepoint and GC poll page is unmapped

2) When leaving and entering foreign code

3) At other occasions.

Safepoints have several responsibilities.

1) If there is a GC or thread interruption in progress, thread has to notify the master thread that it is has reached a safepoint. Safepoint does this by changing the state to STATE_SUSPENDED_BRIEFLY and waiting for state to be changed by master thread. When it resumes, thread checks whether it should suspend or interrupt.

2) If thread should suspend, it is checked whether thread can suspend. If thread is suspendable, it changes its state to STATE_SUSPENDED; otherwise, it sets STOP_FOR_GC_PENDING (and sets pseudo_atomic_interrupted)

3) If thread should interrupt, it either sets INTERRUPT_PENDING and pseudo_atomic_interrupted or executes interruption.

4) If GC is pending and thread can do GC, runs the GC

5) Is interrupt is pending and thread can execute it, executes it.

On some occasions, runtime is in very fragile state and can not really do anything that safepoint must do (e.g., change thread state, execute GC, execute interruption). Thses are e.g. using lisp thread synchronization primitives. To control this, *DISABLE-SAFEPOINTS* variable is used.

GC code is run inside a safepoint, and safepoint code is not reenterable. GC code itself has safepoints (since SUB-GC is a normal lisp function, it calls lisp synchronization routines and does several switches to/from foreign code). To prevent rentering of a safepoint code, *IN-SAFEPOINT* variable is used.

The updated version of implementation notes is published on http://dmitryvk.github.com/sbcl-win32-threads/implementation-notes.html

Since I've asked for a review of Windows threads patches for SBCL, I'm publishing my implementation notes.

Thread-local storage

For each thread Windows allocates a Thread Information Block[1,2,3]. Thread can access its own Thread Information Block through FS register (e.g., %fs:0 is a first field in TIB). TIB contains frequently used thread-specific data such as Last Error Number, pointer to SEH (structured exception handling) frame, thread id, TLS backing store. This structure is documented in [2] and [4]. TIB has an 'Arbitrary' field which is described as:

The 14h DWORD pvArbitrary field is theoretically available for applications to use however they want. It's almost like an extra thread local storage slot for you to use, although I've never seen an application use it.

This sound just like what is needed for implementing thread-local storage - a memory location that we can freely use and that is easily accessible. Unfortunately, there is a possibility that some library would also use this field for own purposes (and there are examples of such libraries). And when come other conflicting code will run, it will be hard to detect this.

So clearly, a better way of storing TLS pointer is needed. TIB also contains first 64 slots for Windows' thread-local storage. There are several options of how to implement TLS with that:

  1. Windows allows executables to preallocate TLS slots. We can take, e.g., TLS slot 0 and all access to lisp's TLS will go through TLS at fixed offset. But it would somewhat complicate the initialization of lisp runtime and the build process.
  2. We can grab some other fixed slot number by successively allocating TLS slots (TlsAlloc) until we get the slot we want. After that, we free the slots we allocated that we don't need. This way, we can use TLS slot 63 and lisp's TLS pointer will be at a known offset. It is quite safe to use this slot; libraries on Windows commonly use no more that one TLS slot (and 'system' libraries don't even use TLS — they have their own fields in TIB).
  3. We can allocate the TLS slot in normal Windows way and store it in a global variable. This way, we don't need to any strange things, but TLS access will have one more indirection. This way would further complicate SBCL's TLS access because macros and code generation assume that TLS does not require any indirection. This is clearly the best way to go but it require more changes.

Currently TLS is implemented as option 2. On initialization, we take the slot 63 (or fail if we couldn't - but I can't imagine the situation where this can happen). When windows-threads will be merged to SBCL, we should consider the option 3 because if implemented now, the change would be more than necessary for windows threading support.

TLS notes:

1. This only applies to Win32. Win64 is very different

2. Clozure CL, on the other hand, uses ES segment register and undocument Win32 functions to allocate a segment and store it in a segment register. This is problematic because WOW64 (the Windows subsystem to run Win32 applications in Windows 64) does not preserve the value of ES register during context switches (including thread preemtion). This is the reason why 32-bit version of Clozure CL does not work on Windows 64.

[1]http://en.wikipedia.org/wiki/Win32_Thread_Information_Block

[2]http://www.microsoft.com/msj/archive/s2ce.aspx

[3]http://msdn.microsoft.com/en-us/library/aa232399(VS.60).aspx

[4]http://www.microsoft.com/msj/archive/s2cea.htm

Thread suspension and interruption

Lisp code in SBCL runs in managed environment — SBCL needs to be able to safely suspend threads (because it uses stop-the-world garbage collector) and interrupt them (i.e., call some function in specific thread).

On Unix-like systems, suspending and interrupting threads is simple (but correctly synchronizing threads and writing code that is tolerable to asynchronous interruptions is not simple). This is achieved by using POSIX signals.

Windows API, on the other hand, does not provide equivalent asynchronous interruptions. They have to be emulated. I'll list several ways of emulating them:

1. 'Thread hijacking'. Windows lets stop a thread, examine its context, modify it and restart a thread. This way, another thread may modify the stack and registers in such a way that thread will execute some other function and then return to where it was.

This might seem the way to emulate signals, but it is not. This is racy with Windows internals in several ways[5].

2. QueueUserAPCEx. Windows has support for Asynchronous Procedure Calls (APCs), but only 'kernel APCs' can interrupt thread when it is in user mode. 'kernel APCs' are available from drivers and there is project called QueueUserAPCEx[6] that provides support for them.

This has a very big drawback that it requires a kernel-mode driver to be installed to function correctly. Because of that, I haven't tried it. By the way, pthreads_win32[7] project will use QueueUserAPCEx if it's available.

3. Thread polling. Thread periodically checks if it should suspend or interrupt. This does not require any special support from operating system. But it requires injecting polling into the code and to tolerate potentially infinite delays (they may occur when thread executes a blocking operation).

Among the noted approaches, the only one that really works is the last one. So it's clear that we have no other choice but to implement polling in SBCL threads. Luckily, I didn't even need to do this myself — Paul Khuong implemented 'gc safepoints' for SBCL[8][9]. The safepoints are implement by reading a specific memory location and discarding the result (e.g., with `test %eax, GC_POLL_PAGE_ADDR` instruction). In normal conditions, this instruction will not have effect on the running code. But if the memory page is read-protected or unmapped, this will cause and exception and we will land in exception handler where we can analyze what has happened and what to do next.

In the next post I'll write about how threads are synchronized for timely suspension and interruption.

[5]http://translate.google.com/translate?js=n&prev=_t&hl=en&ie=UTF-8&layout=2&eotf=1&sl=ru&tl=en&u=http://blog.not-a-kernel-guy.com/2010/05/04/812

[6]http://www.codeproject.com/KB/threads/QueueUserAPCEx_v2.aspx

[7]http://sourceware.org/pthreads-win32/

[8]http://www.pvk.ca/Blog/LowLevel/VM_tricks_safepoints.html

[9]http://repo.or.cz/w/sbcl/pkhuong.git/shortlog/refs/heads/gc-safe-points

Собрал и выложил текущую версию SBCL'а с поддержкой нитей: https://sites.google.com/site/dmitryvksite/sbcl-distr/sbcl-1.0.43-threads-g002bdc7.msi.

Собрал виндовый инсталлятор SBCL 1.0.42 (раньше использовал 1.0.40) с поддержкой нитей.

https://sites.google.com/site/dmitryvksite/sbcl-distr/sbcl-1.0.42-threads.msi

Выложил для тестирования бинарную сборку SBCL с нитями в https://sites.google.com/site/dmitryvksite/sbcl-distr/sbcl-1.0.40-threads-2.msi. Если есть возможность прогнать тесты (два основных теста, которые я использовал при отладке я выложил в http://gist.github.com/582848) и отписаться о результатах, было бы здорово.

Продолжаю натыкаться на всякие косяки.

Недавно завершился растянувшийся на несколько недель сеанс отладки — SBCL зависал при загрузке cl-gtk2 из SLIME'а, но ровно до тех пор, пока в буфер *inferior-lisp* не будет введено хоть что-нибудь, после чего загрузка продолжалась успешно; при загрузке cl-gtk2 из консоли не было проблем.

Сперва я грешил на то, что зависание происходит где-то внутри рантайма SBCL из-за дедлока или чего-то подобного. Перепробовал различные варианты отладочного вывода и анализа логов, но это не дало никаких результатов.

Отладка SBCL'а обычным отладчиком невозможна — SBCL использует инструкции «аппаратная точка останова» (int 3) для обработки прерываний, и отладчики постоянно останавливаются (хотя в последних версиях SBCL вместо int 3 можно использовать инструкцию UD2, которая не должна вызывать остановку отладчика). Единственное, что доступно для отладки — стек всех нитей SBCL в момент зависания. Стеки, конечно же, не информативные в плане информации о лисповом коде.

Дихотомией по коду cl-gtk2 удалось найти место зависания — вызов функции gtk-init-check. В момент зависания в стеке был вызов GdiPlus.dll!__DllMainCRTStartup, под которым был вызов kernel32.dll!_LoadLibraryExW, что навеяло определенные соображения о том, что зависает загрузка GdiPlus.dll.

Проверка этой гипотезы показала, что если запустить SBCL из консоли, то вызов (load-shared-object "GdiPlus.dll") завершается без ошибок, а в REPL — зависает.

Непосредственно зависание происходило внутри функции GetFileType, описание которой на MSDN вполне безобидное («Retrieves the file type of the specified file.»). Полезным оказался комментарий в MSDN про то, что GetFileType иногда зависает. Гугление по «GetFileType hangs» выявило следующее.

For instance, GetFileType on a pipe _hangs_ if there is a pending read request, which, BTW, causes the DLL load to hang sometimes, since the C runtime startup in the DLL calls GetFileType on all known handles while setting up the file descriptor table for open/fopen.

Я уже привык к тому, что почти все от microsoft слабо документировано, но к таким неожиданностям заранее невозможно приготовиться. В данной ситуации одновременное наложение нескольких косяков:

  • Отсутствие внятной документации
  • Плохое, неожиданное и рискованное поведение функций работы с вводом-выводом в Win32
  • Тот факт, что базовый элемент win32 — msvcrt использует небезопасную функцию

Главное, что закравшееся сомнение в некорректности поддержки нитей в sbcl развеялось, и я их скоро подготовлю для более широкого тестирования. А с этим багом будет надо придумать, что делать.

Оказывается, я так до сих пор и не реализовал правильно остановку нитей. Проблемы связаны с вызовом лиспового кода сишной функцией funcall: неправильно изменяется состояние нити (нить входит в лисповый код, поэтому флаг gc_safe должен быть сброшен, но этого не происходит => сборщик мусора пропускает такую нить, и начинает сборку при работающем мутаторе кучи). Также, по-видимому, неправильно обрабатывается изменение состояния нити при раскрутке стека. Видимо, gc_safe стоит вынести из структуры нити и сделать символом; это обеспечит корректное изменение при раскрутке стека и прочих изменениях состояния нити, т.к. стек динамических привязок и так поддерживается в правильном состоянии.

Немного попилил сокеты под виндой. Они очень странные. Так, например, нельзя просто так читать, ожидать и писать в сокеты из разных нитей - если чтение/ожидание началось раньше записи, то запись в сокет не будет произведена, пока не завершится чтение/ожидание. Поэтому, например, без особых ухищрений Slime не работает в многонитевом режиме, т.к. в нем одна нить читает из сокета, а другая - пишет в сокет. Обращаться к асинхронному вводу-выводу очень не хочется.

Закончил еще несколько полезных исправлений в поддержке виндовых нитей в sbcl

  • Используется SBCL 1.40, а не 1.36
  • Почищен код, чтобы было проще мержиться
  • Для реализации "безопасных точек" используются атрибуты защиты страниц. Это довело быстродействие практически до уровня SBCL без нитей. Если раньше цикл (loop repeat (expt 10 8)) на моем компьютере выполнялся 1.529 секунды, то теперь он выполняется за 0.089 секунд.
  • Поддержка прерывания нити функцией SB-THREAD:INTERRUPT-THREAD
  • Исправление ряда ошибок в синхронизации нитей. Еще не все ошибки исправлены, в тестах есть редкие сложно воспроизводимые падения.

Исходники выложены на github (http://github.com/dmitryvk/sbcl-win32-threads), бинарник - http://sites.google.com/site/dmitryvksite/sbcl-distr/sbcl-1.0.40-threads-1.msi.

Пришлось серьезным образом переделать обработку прерываний в sbcl/win32, в очередной раз переписать остановку и прерывание нитей. После этого наконец-то успешно проходят тесты, не проходившие ранее. В связи с чем успешное разрешение ситуации с тредами видится все ближе, но объем изменений становится все больше и больше, а уверенность в быстром вливании изменений в основную ветку все уменьшается. Но в качестве отдельного форка изменения вполне поддерживаемые.

Код пока не выложен, так как он требует некоторой доработки и чуть большего тестирования.

По наводке avodonosov обнаружил и исправил еще два бага, связанных с нитями, приводящих к утечке памяти и хэндлов.

Новая версия бинарника лежит тут: http://sites.google.com/site/dmitryvksite/sbcl-distr/sbcl-1.0.36.16-threads-3.msi.

После добавления нитей в sbcl-win32 неправильно работали коллбэки из сишных функций: при выполнении лиспового кода в коллбэке у нити был установлен флаг gc_safe, что означает, что "нить находится в безопасном для сборки мусора участке кода и обещает не трогать лисповые объекты". Но это очевидно неверно, поэтому в коллбэках нужно сбрасывать этот флаг, а после сброса флага - проверять, а не нужно ли нити остановиться для сборки мусора. Аналогично, при возвращении в сишный код следует устанавливать этот флаг и проверять условие остановки нити.

Исправленные исходники уже есть на гитхабе, а исправленные бинарники - http://sites.google.com/site/dmitryvksite/sbcl-distr/sbcl-1.0.36.16-threads-2.msi.

Следующая задача - починить работу read-char-no-hang для сокетов на винде.

Выложил первую версию SBCL с нитями на http://sites.google.com/site/dmitryvksite/sbcl-distr/sbcl-1.0.36.16-threads.msi. В связи с этим прошу читающих меня лисперов, пользующихся виндой, всячески протестировать и описать обнаруженные баги. В частности, интересует работоспособность под различными версиями винды (xp/vista/7/2k3/2k8; x86/x64).

В выложенной версии нити должны в целом работать, за исключением функций sb-thread:interrupt-thread и sb-thread:terminate-thread (если я ничего не забыл и нигде не накосячил), но производительность в этой версии может сильно отличаться от безнитевой версии, т.к. ряд вещей сделан топорно и без заботы о быстродействии. В частности, холостой цикл вида (loop repeat (expt 10 8)) исполняется в ~10 раз дольше (т.к. он по сути становится не холостым).

SLIME работает, но ведет себя несколько странно.

Попытался запустить для тестов hunchentoot под виндовым SBCL — а он не запускается, потому что в usocket не реализовано неблокирующее чтение, потому что в sbcl-win32 не реализовано неблокирующее чтение из сокета (а также проверка наличия данных в сокете).

Всем откликнувшимся, протестировавшим и отписавшимся - заранее спасибо!

PS. По поводу того, как более информативно сообщать о проблемах. Есть утилита DebugView - она показывает отладочный вывод (совершаемый функцией OutputDebugString) программ, в том числе и вывод данной версии SBCL'а. Если к описанию проблемы будет приложен текстовый файлик с отладочным выводом, то это здорово облегчит поиск и устранение проблем.

Остановку нитей с использованием safepoint'ов на win32 оказалось реализовать гораздо проще, чем с использованием асинхронного взаимодействия (как я уже много раз повторял, win32 sucks, потому что в нем нет сигналов; кстати, большая часть .net-софта, как я понимаю, является async-unsafe). Код вставки safepoint'ов я взял у Paul Khuong (http://repo.or.cz/w/sbcl/pkhuong.git/shortlog/refs/heads/gc-safe-points), но сами safepoint'ы пока реализованы не оптимальным образом.

Теперь для корректности работы сборщика осталось реализовать отслеживание регионов кода, в которых нить не требует остановки для сборки мусора - это те участки, где выполняются операции с блокирующим ожиданием, а также вызовы foreign-функций.

А потом останется соптимизировать.

Пока доработка нитей в sbcl зашла в тупик: не удалось реализовать остановку нитей для сборки мусора. Основная проблема в том, что в win32 нет асинхронных сигналов (которые есть практически на всех других платформах и представлены вызовом pthread_kill), поэтому вся асинхронность реализуется самостоятельно. Я перебрал несколько вариантов:

  1. Нить в pseudo-atomic секции останавливается сама, во всех остальных случаях - SuspendThread. Но это чревато проблемами в некоторых местах: нити иногда просто зависали, были ошибки внутри сборщика памяти (обработчик write-protect обнаруживал нарушение одного из инвариантов). Я это связал с тем, что нить останавливаются не в том месте: для обозначения непрерываемых участков кода sbcl использует не только pseudo-atomic-секции, но и маску сигналов нити.
  2. Дальше я добавил к нити маску заблокированных сигналов и учитывал их при остановке нити: нить останавливается с помощью SuspendThread, затем если нить заблокировала сигнал SIG_STOP_FOR_GC, то нить размораживается с помощью ResumeThread, а мы спим некоторое время и пытаемся снова остановить ее. Теперь зависания нитей почти прекратились, но стало проявляться другое поведение: как только сборка мусора завершается и нити размораживаются, некоторые нити обнаруживают некорректные флаги защиты страниц памяти. Это я связываю с тем, что нить останавливается внутри обработчика исключения, и у нее виден контекст обработчика исключения, а не контекст нити.

То есть, асинхронно не получится прерывать нити. Заодно посмотрел, как .NET'овский сборщик мусора останавливает нити. В .NET CLR у каждой нити есть событие "нить остановлена". Компилятор вставляет инструкции (gc safepoint), чтобы нить время от времени проверяла, надо ли есть остановиться. Конкретные инструкции не так важны, например, это может быть чтение из области памяти, которую сборщик unmap'ит, что вызывает исключение. Когда нить покидает managed code (то есть, уходит в ожидание, либо в foreign-код), то ставится флаг, что нить в безопасном относительно сборки мусора состоянии, а про возврате в managed code нить проверяет, не пора ли остановиться. При этом, если нить за 250миллисекунд не остановилась, то сборщик ее останавливает через SuspendThread. Нить может не остановиться, если она в течение долгого времени не достигает safepoint'ов (например, нить исполняет долгий цикл без вызовов методов или выделения памяти) - т.е., в этом отношении .net gc менее аккуратен, чем, например, sun jvm, в которой safepoint'ы вставляются не только на вызов методов, но и на переходы назад (что всегда встречается в цикле), гарантируя, что нить через ограниченное время достигнет safepoint.

Я тоже попробую применить подобную технику. safepoint'ы будут располагаться в следующих местах: при выходе из pseudo-atomic-секции, при смене маски сигналов нити, в обработчике trap-инструкций, при возврате из foreign-кода и из блокирующих операций. При входе в foreign-код и выполнении блокирующих операций будет подниматься флаг, что нить ничего не делает с кучей, и поэтому она безвредна. Так как я не хочу глубоко залезать в компилятор, то попробую делать так: сперва все нити останавливаются; затем каждая нить анализируется: если нить безвредна для сборщика мусора, то она отпускается; если нить в pseudo-atomic-секции или у нее заблокирован сигнал SIG_STOP_FOR_GC, то она также отпускается и ожидается ее завершение; если она выполняет лисповый код, то ближайшую инструкцию jmp или call заменяем на trap (т.е., ставим breakpoint), запускаем нить и ждем ее - нить обязательно скоро доберется до точки останова, и обработчик исключения вернет инструкцию на место, и либо остановит нить (если нить не в pseudo-atomic-секции. Пока не придумал, что делать, если нить исполняет не лисповый код и не находится в foreign-коде, псевдоатомарной секции, и сигналы незаблокированы. В таком случае, видимо, можно пробуждать код, ждать немного и пробовать еще раз.

sbcl'ные нити под виндой показывают успехи.

В качестве теста использую приведенный ниже код. Он запускается и уже не зависает на моем компьютере, как раньше. Т.е., работает одновременное выделение памяти в нескольких нитях, которые периодически вызывают сборщик мусора (причем зачастую сборщик мусора вызывается не в одной нити, а в нескольких одновременно). Надо будет его проверить на рабочей 4-ядерной машине хотя бы несколько часов, чтобы убедиться в более или менее правильности подхода, но вроде пока все правильно.

(defun cons-lot (stream char)
  (sleep 2)
  (loop
    (loop repeat 10
      for ar = (make-array (* 1024 1024 1/2) :initial-element 0)
      do (setf (aref ar 1000) 112)
)

    (format stream "~A" char)
    (finish-output stream)
)
)


(defun threaded-cons-lot (n)
  (loop
    with stream = *standard-output*
    repeat n
    do (sb-thread:make-thread (lambda () (cons-lot stream #\.)))
)
)


(threaded-cons-lot 4)

Все-таки не хватает в win32 асинхронных IPC. Корректная остановка нити, которая в posix достижима (нужно аккуратно и правильно манипулировать маской сигналов нити, чтобы сигнал не пришел в критические моменты), в win32 становится очень хрупкой, т.к. единственное, что можно сделать - это грубо остановить нить с помощью SuspendThread, проанализировать ее состояние (что само по себе нетривиально), можно как-то поменять контекст нити; или же перейти на синхронную остановку нити, т.е. нить должна периодически проверять, а не надо ли ей остановиться/прерваться. Я не эксперт в этой области, но мне это кажется существенным недостатком средств межпроцессного взаимодействия.

(Кстати, с асинхронным взаимодействием в win32 могут быть связаны хитрые баги: если нить ожидала на Event, а другая нить сделала PulseAll (т.е., пробудить все нити, ожидающие на событии), то ожидающая нить может и не пробудиться из-за того, что в нити может выполняться kernel asynchronous procedure call).

В SBCL сборка мусора с остановкой нитей реализована так:

  1. Нить обнаруживает, что надо собрать мусор. Нить это может обнаружить либо во время выделения памяти (тогда нить находится в псевдоатомарной секции, и сигналы заблокированы), либо при явном вызове SB-EXT:GC (тогда нить не в псевдоатомарной секции).
  2. Нить захватывает мьютекс *ALREADY-IN-GC*. При этом захват происходит с разблокированными сигналами. Это нужно, чтобы несколько нитей могли пытаться войти в сборку мусора: тогда одна нить получает мьютекс, а другие нити будет можно остановить, т.к. при взятии мьютекса сигналы разблокируются, и им можно будет послать сигнал остановки.
  3. Нить, захватившая мьютекс, посылает всем другим нитям сигнал SIG_STOP_FOR_GC, и ждет когда остановятся. Когда нить останавливается, она меняет поле thread.state в значение STATE_SUSPENDED и сигнализирует переменную условия state_cond.
  4. Нити принимают сигналы остановки и останавливаются. Ключевой момент состоит в том, что нить примет сигнал только тогда, когда готова его обработать, и примет его как только она будет готова его принимать; например, если нить находится в заблокированном состоянии, ожидая пока освободится мьютекс *ALREADY-IN-GC*, то она тоже сможет принять сигнал и остановиться, уведомив нить, выполняющую сборку мусора
  5. Когда все нити остановлены, производится сборка мусора. В это время другие нити ждут (ожидание ведется на переменных условия state_cond).
  6. У нитей останавливается состояние STATE_RUNNING, и нити пробуждаются.

В win32 проблемы с пунктом 4, т.к. нет способа заставить нить выполнить код так, чтобы это было безопасно для нити и чтобы нить в это время не находилась в каком-нибудь особом состоянии. Если пренебречь первым требованием, то можно использовать SetThreadContext и направлять нить на любой код; если пренебречь вторым требованием, то можно использовать QueueUserAPC (но user asynchronous procedure calls вызываются только в особые моменты нити - когда нить выполняет SleepEx, WaitForSingleObjectEx, WaitForMultipleObjectsEx); есть еще один прием, который используется в некоторых JVM'ах - в определенные точки программы вставлять обращения к специальной области памяти, а когда нужно остановить или прервать нить, выставлять этой области защиту от записи/чтения - тогда в той нити произойдет ошибка доступа к памяти, и обработчик ошибки сможет проанализировать обстоятельства и понять, что нити надо оставиться или выполнить какой-то код - но это тоже подразумевает, что нить должна периодически совершать какие-то действия.

Поэтому я поступаю следующим образом. Раз не получается заставить нить выполнить нужный код безопасным образом, то придется останавливать нить грубо, используя SuspendThread. Для сборки мусора есть две опасности - если другая нить захватит объекты синхронизации, которые используются сборщиком мусора; и если сборщик мусора увидит незавершенную инициализацию объекта. Поэтому все блокировки, которые используются при сборке мусора и остановке нитей, захватываются в начале сборки мусора. Незавершенная инициализация объекта соответствует псевдоатомарной секции; поэтому у такой нити можно установить флаг "pseudo-atomic-interrupted", чтобы нить в конце псевдоатомарной секции прервалась и остановилась; если останавливаемая нить в псевдоатомарной секции будет делать сборку мусора, то она будет попытается захватить мьютекс - поэтому для захвата мьютекса используется не pthread_mutex_lock, а pthread_mutex_trylock - нить, которая захватывает мьютекс, запускает сборку мусора, а остальные останавливаются; нить в "обычном" состоянии просто останаливается с SuspendThread - при этом все их блокировки не освобождаются, и их не может использовать сборщик мусора. Остановка и запуск делаются с помощью пары AutoResetEvent: gc_suspend_event, gc_resume_event.

Теперь надо проверить остановку нитей и придумать, как делать прерывание нитей (иначе, как я понимаю, в SLIME не будет работать остановка "зависших" вычислений).

Чуть дальше продвинулся в поддержке нитей в SBCL. Теперь нити запускаются, и даже сборщик мусора работает при нескольких запущенных нитях (это пока не совсем так, не совсем корректно реализована остановка нитей - gc_stop_the_world не сработает в случае, если нить находилась в псевдоатомарной секции, но это несложно дописать). Простейшие примеры работают.

Зато я теперь не уверен в том, что можно корректно останавливать нити с помощью вставки фиктивного вызова функции в другой нити. Ведь пока остановленная нить внутри фиктивного вызова функции ждет пробуждения нити, юзерспейс-код из какой-нибудь системной библиотеки мог захватить какие-нибудь ресуры (не рассчитанные на то, что кто-то возьмет и остановит на какое-то заметное время нить, их использующую), которые могут потребоваться во время сборки мусора.

Если это так, то придется искать другие механизмы остановки нитей. Например, можно отказаться от требования полной остановки нити - достаточно будет того, что лисповая часть стека и лисповая куча остается нетронутой нитью. Но это потом, если выяснится, что нить останавливать нельзя.

(Многие мои рассуждения могут показаться наивными - в целом, так оно и есть, я довольно плохо разбираюсь в компиляторах, ОСях и в винде, а это - просто интересное хобби)

Продолжаю ковырять SBCL на предмет ниток в Win32. Указателя на область TLS хранится в поле Arbitrary Data Slot, которое для каждой нити всегда доступно по адресу FS:0x14. Часть VOP'ов из-за этого занимает больше инструкций и используют больше регистров и обращений к памяти - в других портах SBCL может сразу адресовать область TLS, а Win32 надо еще загрузить в регистр указатель на эту область. Пока сложно оценить, насколько это повлияет на быстродействие.

Собственная невнимательность мне всегда мешает. На отладку потратил всю неделю, а все по невнимательности. Зато узнал, что GDB - очень удобный отладчик. За неприметным текстовым интерфейсом это сразу сложно разглядеть. В GDB можно писать макросы, за счет чего он быстро обрастает оснасткой для конкретных приложений. Раньше я интенсивно использовал лишь отладчик в Visual Studio, и не ожидал, что GDB будет настолько удобным.

Что касается SBCL, то сегодня он впервые полностью собрался и заработал (раньше доходил только до начала cold init'а). TLS, судя по всему, работает.

Теперь нужно допилить рантайм, научить его останавливать создавать нити и останавливать их при сборке мусора.

Для остановки нитей буду помещать в контекст нити фиктивный вызов функции (plant_stack.c), в которой ждать сигнала к пробуждению. Это позволит корректно останавливать как нити, находящиеся в блокирующем системном вызове, так и нити, находящиеся в юзерспейсе.

Сейчас мне идея использовать GS для хранения указателя на данные нити кажется не такой хорошей. Несмотря на все мои усилия, GS иногда оказывается равным нулю.

Кроме GS есть еще варианты, как можно хранить локальные данные:

  • В регистре ES. Clozure CL под win32 (но не win64) использует регистр ES для адресации локальных данных, и не использует цепочечные ассемблерные опкоды (видимо, не используются они, потому что в Clozure только EAX хранит нетегированное значение; кстати, такое разбиение набора регистров позволяет сборщику мусора в CCL быть точным). Соответственно, для SBCL это выльется в исключение генерации цепочечных опкодов в компиляторе и сохранении ES при вызове внешнего кода. В общем, печальная ситуация.
  • В Thread Information Block в поле Arbitrary Data. Это поле для текущей нити всегда доступно по адресу %FS:0x14. Использование этого выльется в переписывание нескольких мест, работающих с текущей нитью. Судя по беглому просмотру кода, таких мест немного и там не используются хитрые трюки.

Пока что мой основной вариант - это использовать %FS:0x14.

Page generated Apr. 4th, 2026 10:31 pm
Powered by Dreamwidth Studios