Я написал собственный диспетчер блокировок, поэтому стал подозревать его. Но ведь он без проблем прошел модульные тесты! Наконец я определил, что виноват был не он, а реализация мьютексов в нижележащем слое. Тогда операционные системы еще не поддерживали многопоточность, и пакет для ее поддержки нам пришлось писать самим. Вышло так, что разработчик, отвечавший за код мьютексов, случайно перепутал метки подпрограмм «заблокировать» и «попробовать заблокировать» в ассемблерной реализации потоков в Solaris. Так что каждый раз, когда вы думали, что вызываете безусловную блокировку, на самом деле она только пыталась произойти, и наоборот. И когда случался конфликт — в то время редкость, — второй поток оказывался в критической секции, как если бы в первом потоке не было блокировки. Самое забавное, что вся компания на несколько недель оказалась без мьютексов, и никто не заметил.
В своей превосходной статье «Engineering a Sort Function» (Разработка функции Sort) Бентли и Макилрой цитируют чудесное высказывание Кнута насчет приведения себя в самое поганое настроение, на которое только вы способны. Как раз это я и сделал для той серии тестов. Но это сделало ошибку крайне трудно обнаружимой. Прежде всего, из-за многопоточности каждый случай оказывался почти невоспроизводимым. Далее, оказались ложными мои представления не о чем-нибудь, а о ядре системы. Обычно начинающие программисты легко приходят к выводу, что язык или система не в порядке. Но тут базовая конструкция, на которую я опирался, — мьютекс — действительно оказалась сломанной.
Сейбел: Итак, ошибка содержалась не в вашем коде, но вы тем временем написали столь подробные тесты для кода, что ошибку волей-неволей пришлось искать вне его. Как по-вашему, мог ли — или должен ли был — автор мьютексов написать тесты для нахождения этой ошибки, которые избавили бы вас от полутора недель отладки?
Блох: Мне кажется, хорошая автоматическая программа проверки мьютексов спасла бы меня от мучений, но не забудем, что это было в начале 1990-х. Мне и в голову не приходило винить разработчика за то, что он не создал достаточно хороших модульных тестов. Даже сегодня писать модульные тесты для многопоточных программ — подлинное искусство.
Сейбел: Мы говорили о пошаговом прохождении кода. А какими средствами отладки вы пользуетесь сейчас?
Блох: Наверное, я кажусь неандертальцем, но важнейшие инструменты для меня, как и раньше, — мои глаза и мозг. Я распечатываю все необходимые фрагменты кода и очень внимательно их изучаю.
Отладчики — хорошее средство, и порой мне хочется пользоваться оператором print, но вместо этого я прибегаю к точке останова. Время от времени я применяю отладчики, но и без них чувствую себя вполне уверенно. Имея возможность использовать операторы print и внимательно читать код, я вполне могу находить ошибки.
Я уже говорил, что пользуюсь операторами утверждения для проверки сохранности сложных инвариантов. Если инварианты ломаются, я хочу знать, когда это случилось, какие действия привели к этому.
Кстати, я вспомнил еще одну труднонаходимую ошибку. Правда, не могу сказать точно, было это в Transarc или на последнем курсе Университета Карнеги-Меллона, когда я работал над системой распределенных транзакций Camelot. He я нашел эту ошибку, но сам случай меня глубоко поразил.
У нас был трассировочный пакет, позволявший коду выводить отладочную информацию. Каждое отслеженное событие снабжалось меткой с указанием идентификатора потока, где оно произошло. Иногда идентификаторы оказывались неверными, и мы не понимали, почему. Наконец, мы решили, что с этой ошибкой можно еще пожить сколько-то времени, — она казалась безобидной.
Но выяснилось, что ошибка не в трассировочном пакете — все было гораздо серьезнее. Чтобы найти идентификатор потока, трассировочный пакет вызывал код из потоковой библиотеки. А тот делал штуку, очень в то время распространенную: смотрел старшие биты адреса стековой переменной. То есть он брал значение указателя стековой переменной и сдвигал его вправо на фиксированное число позиций, получая таким образом идентификатор потока. Дело в том, что у каждого потока был стек определенного размера, который выражался заранее известной степенью двойки.
Выглядит логично, так? Но, к сожалению, те, кто создавал объекты в стеке, делали их слишком большими по тогдашним меркам. Массив из 100 элементов, по 4 Кбайт каждый, — всего 400 Кбайт в стеке одного потока. Получался перескок через красную зону стека в стек соседнего потока. И мы получали неверный идентификатор потока. Хуже того: когда поток обращался к локальным для потока переменным, он считывал переменные другого потока, поскольку его идентификатор использовался как ключ для доступа к этим переменным.
Итак, то, что мы приняли за безобидный недочет трассировочного пакета, оказалось признаком действительно серьезной ошибки. Событие приписывалось потоку 43 вместо потока 42, так как один поток невольно подменял собой другой, и это могло иметь катастрофические последствия.