(интересно будет скорее всего только программистам)
Пару недель назад я починил баг на работе в одной внутренней библиотеке. Этот баг проявлялся так: долгоживущие сервера, пользовавшиеся этой библиотекой определенным способом, загадочным и случайным образом падали примерно раз в неделю (не все одновременно, а каждый сервер отдельно падал примерно с такой частотой). Найти и починить баг оказалось очень тяжело, потому что к моменту падения следов никаких почти не оставалось. Сам по себе баг тривиален - многопоточный код недостаточно дисциплинированно охранял доступ к глобальному ресурсу, который я назову X. У X есть мьютекс, который надо получить перед тем, как его трогать. Два потока делали следующее:
thread 1: get-mutex-for-X change1-X release-mutex-for-X
thread 2: change2-X
Код, который меняет X, в двух потоках был разный, поэтому я обозначил его change1 и change2, но он трогал одну и ту же часть X, и второй поток банально не охранял свой доступ мьютексом. Изредка, очень изредка, эти два потока выполняли эти куски кода одновременно на разных ядрах. Отыскать это все быстро мешало несколько осложняющих обстоятельств:
- кроме этих двух мест, есть еще десятки мест, которые меняют X, правильно охраняя замену мьютексом, но ни один из них не может конфликтовать с "thread 2"
- сам конфликт между thread 1 и thread 2 не приводил к немедленному падению, а коррумпировал внутренние структуры X, что обычно приводило к падению с загадочной и невнятной ошибкой спустя несколько секунд. К тому времени обычно thread 1 и thread 2 давно уже ушли из этих частей кода и никак не проявлялись в stack trace. Обычно там проявлялись другие места, которые ждали mutex-for-X в момент падения, просто по случайности.
- тот факт, что thread 2 не пользовался мьютексом, не был очевиден из его кода - неважно почему, долго объяснять. Казалось бы, этот код полностью лежал, вместе с еще кучей всего, внутри мьютекса X, но на самом деле он освобождал мьютекс как раз перед change2, и потом брал его заново, и это было неочевидно при чтении кода. Т.е. thread 2 выглядел, если быть точнее, так - троеточие передает "много всякой всячины":
thread 2: get-mutex-for-X ... release-mutex-for-X change2-X get-mutex-for-X ... release-mutex-for-X
Во многих ситуациях такое временное освобождение мьютекса как раз правильное поведение - если бы change2-X не меняло X, а делало какие-то совершенное другие вещи, и притом не очень быстро, то как раз это правильно. Но в данном случае это ошибка.
После того, как я нашел, почему это происходит, я исправил второй кусок кода, убрав неверное освобождение мьютекса, и все стало выглядеть так:
thread 1: get-mutex-for-X change1-X release-mutex-for-X
thread 2: get-mutex-for-X ... change2-X ... release-mutex-for-X
Падений больше не было. Вскоре я уехал в отпуск.
Во время моего отпуска, который еще не закончился, выяснилось следующее. Кусок кода из "thread 2" меняет X и одновременно меняет другой глобальный ресурс Y, который защищен своим собственным мьютексом. Изменение Y происходит внутри "change2-X" с помощью вызова какой-то функции Y, и работа с мьютексом полностью спрятана внутри этой функции и незаметна при чтении кода thread 2. Тем не менее, эта работа происходит, и если ее учесть, thread 2 выглядит так:
thread 2: get-mutex-for-X ... get-mutex-for-Y ... release-mutex-for-Y change2-X ... release-mutex-for-X
Одно из десятков других мест, которые могут менять X и охраняют это мьютексом, происходит внутри кода, связанного с ресурсом Y, и находящегося глубоко внутри цепочки кода, охраняемой мьютексом для Y. Этот код, выходит, выглядит так:
thread 3: get-mutex-for-Y ... get-mutex-for-X change3-X release-mutex-for-X ... release-mutex-for-Y
Интересно, что изменения change2-X и change3-X никак не конфликтуют друг с другом - они меняют разные места X. Но из-за того, что thread 2 и thread 3 получают два мьютекса для X и Y в разном порядке, получается возможность взаимной блокировки (deadlock). Один поток получил мьютекс для X, и еще до того, как он попытался получить мьютекс для Y, второй поток получил его - и все, они зависают, пытаясь отобрать друг у друга те мьютексы, что у них есть. До того, как я исправил код thread 2, блокировки быть не могло, но теперь она возможна.
Эта самая блокировка и стала происходить, примерно с такой же частотой, как первоначальная проблема падения, т.е. довольно редко, но заметно. Так вышло, что пытаясь починить один сложный многопоточный баг, я ввел другой - причем не что иное, как классический deadlock.
Коллега, который разобрался в этой мешанине и нашел мою ошибку, исправил код thread 2 следующим образом. Он вернул освобождение мьютекса-X перед основной работой этого кода, и вставил эксплицитное ожидание этого мьютекса снова - но только после того, как вся работа с Y закончена. Так что выполнение thread 2 имеет теперь такой вид:
thread 2: get-mutex-for-X ... release-mutex-for-X get-mutex-for-Y ... release-mutex-for-Y get-mutex-for-X change2-X release-mutex-for-X get-mutex-for-X ... release-mutex-for-X.
На данный момент ни о каких новых проблемах с этим кодом не известно.
Пару недель назад я починил баг на работе в одной внутренней библиотеке. Этот баг проявлялся так: долгоживущие сервера, пользовавшиеся этой библиотекой определенным способом, загадочным и случайным образом падали примерно раз в неделю (не все одновременно, а каждый сервер отдельно падал примерно с такой частотой). Найти и починить баг оказалось очень тяжело, потому что к моменту падения следов никаких почти не оставалось. Сам по себе баг тривиален - многопоточный код недостаточно дисциплинированно охранял доступ к глобальному ресурсу, который я назову X. У X есть мьютекс, который надо получить перед тем, как его трогать. Два потока делали следующее:
thread 1: get-mutex-for-X change1-X release-mutex-for-X
thread 2: change2-X
Код, который меняет X, в двух потоках был разный, поэтому я обозначил его change1 и change2, но он трогал одну и ту же часть X, и второй поток банально не охранял свой доступ мьютексом. Изредка, очень изредка, эти два потока выполняли эти куски кода одновременно на разных ядрах. Отыскать это все быстро мешало несколько осложняющих обстоятельств:
- кроме этих двух мест, есть еще десятки мест, которые меняют X, правильно охраняя замену мьютексом, но ни один из них не может конфликтовать с "thread 2"
- сам конфликт между thread 1 и thread 2 не приводил к немедленному падению, а коррумпировал внутренние структуры X, что обычно приводило к падению с загадочной и невнятной ошибкой спустя несколько секунд. К тому времени обычно thread 1 и thread 2 давно уже ушли из этих частей кода и никак не проявлялись в stack trace. Обычно там проявлялись другие места, которые ждали mutex-for-X в момент падения, просто по случайности.
- тот факт, что thread 2 не пользовался мьютексом, не был очевиден из его кода - неважно почему, долго объяснять. Казалось бы, этот код полностью лежал, вместе с еще кучей всего, внутри мьютекса X, но на самом деле он освобождал мьютекс как раз перед change2, и потом брал его заново, и это было неочевидно при чтении кода. Т.е. thread 2 выглядел, если быть точнее, так - троеточие передает "много всякой всячины":
thread 2: get-mutex-for-X ... release-mutex-for-X change2-X get-mutex-for-X ... release-mutex-for-X
Во многих ситуациях такое временное освобождение мьютекса как раз правильное поведение - если бы change2-X не меняло X, а делало какие-то совершенное другие вещи, и притом не очень быстро, то как раз это правильно. Но в данном случае это ошибка.
После того, как я нашел, почему это происходит, я исправил второй кусок кода, убрав неверное освобождение мьютекса, и все стало выглядеть так:
thread 1: get-mutex-for-X change1-X release-mutex-for-X
thread 2: get-mutex-for-X ... change2-X ... release-mutex-for-X
Падений больше не было. Вскоре я уехал в отпуск.
Во время моего отпуска, который еще не закончился, выяснилось следующее. Кусок кода из "thread 2" меняет X и одновременно меняет другой глобальный ресурс Y, который защищен своим собственным мьютексом. Изменение Y происходит внутри "change2-X" с помощью вызова какой-то функции Y, и работа с мьютексом полностью спрятана внутри этой функции и незаметна при чтении кода thread 2. Тем не менее, эта работа происходит, и если ее учесть, thread 2 выглядит так:
thread 2: get-mutex-for-X ... get-mutex-for-Y ... release-mutex-for-Y change2-X ... release-mutex-for-X
Одно из десятков других мест, которые могут менять X и охраняют это мьютексом, происходит внутри кода, связанного с ресурсом Y, и находящегося глубоко внутри цепочки кода, охраняемой мьютексом для Y. Этот код, выходит, выглядит так:
thread 3: get-mutex-for-Y ... get-mutex-for-X change3-X release-mutex-for-X ... release-mutex-for-Y
Интересно, что изменения change2-X и change3-X никак не конфликтуют друг с другом - они меняют разные места X. Но из-за того, что thread 2 и thread 3 получают два мьютекса для X и Y в разном порядке, получается возможность взаимной блокировки (deadlock). Один поток получил мьютекс для X, и еще до того, как он попытался получить мьютекс для Y, второй поток получил его - и все, они зависают, пытаясь отобрать друг у друга те мьютексы, что у них есть. До того, как я исправил код thread 2, блокировки быть не могло, но теперь она возможна.
Эта самая блокировка и стала происходить, примерно с такой же частотой, как первоначальная проблема падения, т.е. довольно редко, но заметно. Так вышло, что пытаясь починить один сложный многопоточный баг, я ввел другой - причем не что иное, как классический deadlock.
Коллега, который разобрался в этой мешанине и нашел мою ошибку, исправил код thread 2 следующим образом. Он вернул освобождение мьютекса-X перед основной работой этого кода, и вставил эксплицитное ожидание этого мьютекса снова - но только после того, как вся работа с Y закончена. Так что выполнение thread 2 имеет теперь такой вид:
thread 2: get-mutex-for-X ... release-mutex-for-X get-mutex-for-Y ... release-mutex-for-Y get-mutex-for-X change2-X release-mutex-for-X get-mutex-for-X ... release-mutex-for-X.
На данный момент ни о каких новых проблемах с этим кодом не известно.
no subject
Date: 2010-08-20 01:50 am (UTC)no subject
Date: 2010-08-20 01:56 am (UTC)Решение коллеги, кстати, не идеально. Я бы, наверное, попытался разделить изменение X и Y по времени, более того, сделал бы доступ к полям X и Y исключительно через аксессоры/мутаторы, в которых бы и брал нужные мютексы.
Помимо всего прочего, это могло бы увеличить эффективность за счёт более высокой ганулярности сериализаций.
no subject
Date: 2010-08-20 01:57 am (UTC)скорее всего только программистам
Date: 2010-08-20 02:01 am (UTC)no subject
Date: 2010-08-20 02:11 am (UTC)Кстати, ИМХО, это очень часто ошибка. Потому что код до первого освобождения мьютекса, обычно проверяет какое-то важное предусловие для последующего изменения. И, если это предусловие не проверяется повторно после "финального взятия" мьютекса, случаются чудеса.
no subject
Date: 2010-08-20 03:01 am (UTC)no subject
Date: 2010-08-20 03:17 am (UTC)no subject
Date: 2010-08-20 03:23 am (UTC)Из любпытство - насколько необходим общий mutex для всего Х? Была ли возможность заменить на, скажем, атомик переменные, или скажем все изменения такого рода посылаются в background thread? Если бы вы начинали делать новый дизайн.
no subject
Date: 2010-08-20 04:13 am (UTC)no subject
Date: 2010-08-20 04:15 am (UTC)no subject
Date: 2010-08-20 04:27 am (UTC)no subject
Date: 2010-08-20 05:07 am (UTC)Для Линуксов есть Helgrind
no subject
Date: 2010-08-20 05:08 am (UTC)no subject
Date: 2010-08-20 05:31 am (UTC)no subject
Date: 2010-08-20 06:15 am (UTC)no subject
Date: 2010-08-20 06:17 am (UTC)no subject
Date: 2010-08-20 06:38 am (UTC)Но на этом уровне deadlock - классическая ошибка с которой все начинают
no subject
Date: 2010-08-20 06:39 am (UTC)no subject
Date: 2010-08-20 06:46 am (UTC)1. ну ясное дело врапперы для инициализации, взятия и отпускания всех локов
2. тред-локальная переменная, считающая локи которые взяты тредом (в его случае это были спинлоки, поэтому переменная процессор-локальная).
3. как и везде, каждый лок имеет Номер В Очереди. Просто здесь он явно указывается при инициализации. Локи с одинаковым номером одновременно не берутся никогда (например, это локи на бакеты хеш-таблицы, зачем благородным донам два бакета одновременно???). Если вдруг берется лок, а лок с таким же или большим номером в очереди на данном треде уже взят -- валим. Если оказалось что первоначальные предположения о порядке оказались неверными -- меняем номера как правильно, запускаем тест заново.
4. Из-за оверхеда с номерами, чтобы быстро работало, количество слоев в иерархии локов должно быть небольшим, например 32. Я так понял что чуваку хватило.
ну, в результате оно будет валиться когда локи берутся не в том порядке, вместо того, чтобы повисать когда локи на одном ядре берутся в том порядке и одновременно на другом ядре -- не в том.
Ну я как бы подумал, городить ли мне такое в том проекте над которым я тогда работал, но потом понял, что у меня все настолько проще (типа, три слоя иерархии, ERESOURCE и два спинлока под ним), что игра не стоит свеч. ERESOURCE я точно после спинлока взять никак не могу :-).
no subject
Date: 2010-08-20 06:49 am (UTC)no subject
Date: 2010-08-20 06:51 am (UTC)no subject
Date: 2010-08-20 06:54 am (UTC)no subject
Date: 2010-08-20 06:59 am (UTC)no subject
Date: 2010-08-20 07:06 am (UTC)no subject
Date: 2010-08-20 07:11 am (UTC)