Создание, анализ ирефакторинг
Скачать 3.16 Mb.
|
373 Через несколько недель отладки мы обнаружили, что причиной зависания была десинхронизация между счетчиком кольцевого буфера и его указателем . Буфер управлял выводом на терминал . Значение указателя говорило о том, что буфер был пуст, а счетчик утверждал, что буфер полон . Так как буфер был пуст, вы- водить было нечего; но так как одновременно он был полон, в экранный буфер нельзя было ничего добавить для вывода на экран . Итак, мы знали, почему зависали терминалы, но было неясно, из-за чего возникает десинхронизация кольцевого буфера . Поэтому мы реализовали обходное решение . Программный код мог прочитать состояние тумблеров на передней панели компью- тера (это было очень, очень, очень давно) . Мы написали небольшую функцию, которая обнаруживала переключение одного из тумблеров и искала кольцевой буфер, одно- временно пустой и заполненный . Обнаружив такой буфер, функция сбрасывала его в пустое состояние . Voila! Зависший терминал снова начинал выводить информацию . Теперь нам не приходилось перезагружать систему при зависании терминала . Когда нам звонили из управления и сообщали о зависании, мы шли в машинный зал и переключали тумблер . Конечно, иногда управление работало по выходным, а мы – нет . Поэтому в пла- нировщик была включена функция, которая раз в минуту проверяла состояние всех кольцевых буферов и сбрасывала те из них, которые были одновременно пустыми и заполненными . Зависший терминал начинал работать даже до того, как операторы успевали подойти к телефону . Прошло несколько недель кропотливого просеивания монолитного ассемблер- ного кода, прежде чем была обнаружена причина . Мы занялись вычислениями и определили, что частота зависаний статистически соответствует одному неза- щищенному использованию кольцевого буфера . Оставалось только найти это одно использование . К сожалению, все это было очень давно . В те времена у нас не было функций поиска, перекрестных ссылок или других средств автоматиза- ции . Нам просто приходилось просматривать листинги . Тогда, холодной зимой 1971 года в Чикаго, я узнал важный урок . Клиентская блокировка — полный отстой . Серверная блокировка Дублирование можно устранить внесением следующих изменений в Integer- Iterator : public class IntegerIteratorServerLocked { private Integer nextValue = 0; public synchronized Integer getNextOrNull() { if (nextValue < 100000) return nextValue++; else return null; } } 373 374 Приложение А . Многопоточность II В клиентском коде также вносятся изменения: while (true) { Integer nextValue = iterator.getNextOrNull(); if (next == null) break; // Действия с nextValue } В этом случае мы изменяем API своего класса, чтобы он обладал многопоточной поддержкой 1 . Вместо проверки hasNext() клиент должен выполнить проверку null В общем случае серверная блокировка предпочтительна по следующим при- чинам: Она сокращает дублирование кода – клиентская блокировка заставляет каж- дого клиента устанавливать соответствующую блокировку сервера . Если код блокировки размещается на сервере, клиенты могут использовать объект, не беспокоясь о написании дополнительного кода блокировки . Она обеспечивает более высокую производительность – в случае однопоточ- ного развертывания потоково-безопасный сервер можно заменить потоково- небезопасным, устраняя все дополнительные затраты . Она снижает вероятность ошибок – в случае клиентской блокировки доста- точно всего одному программисту забыть установить блокировку, и работа системы будет нарушена . Она определяет единую политику использования – политика сосредоточена в одном месте (на сервере), а не во множестве разных мест (то есть у каждого клиента) . Она сокращает область видимости общих переменных — клиент не знает ни о переменных, ни о том, как они блокируются . Все подробности скрыты на сторо- не сервера . Если что-то сломается, то количество мест, в которых следует искать причину, сокращается . Что делать, если серверный код вам неподконтролен? Используйте паттерн АДАПТЕР, чтобы изменить API и добавить блокировку: public class ThreadSafeIntegerIterator { private IntegerIterator iterator = new IntegerIterator(); public synchronized Integer getNextOrNull() { if(iterator.hasNext()) return iterator.next(); return null; } } ИЛИ еще лучше – используйте потоково-безопасные коллекции с расширен- ными интерфейсами . 1 На самом деле интерфейс Iterator в принципе не обладает потоковой безопасностью . Он не проектировался с расчетом на многопоточное использование, так что этот факт не вы- зывает удивления . 374 Повышение производительности 375 Повышение производительности Допустим, вы хотите выйти в сеть и прочитать содержимое группы страниц по списку URL-адресов . По мере чтения страницы обрабатываются для накоп- ления некоторой статистики . После чтения всех страниц выводится сводный отчет . Следующий класс возвращает содержимое одной страницы по URL-адресу . public class PageReader { //... public String getPageFor(String url) { HttpMethod method = new GetMethod(url); try { httpClient.executeMethod(method); String response = method.getResponseBodyAsString(); return response; } catch (Exception e) { handle(e); } finally { method.releaseConnection(); } } } Следующий класс – итератор, предоставляющий содержимое страниц на осно- вании итератора URL-адресов: public class PageIterator { private PageReader reader; private URLIterator urls; public PageIterator(PageReader reader, URLIterator urls) { this.urls = urls; this.reader = reader; } public synchronized String getNextPageOrNull() { if (urls.hasNext()) getPageFor(urls.next()); else return null; } public String getPageFor(String url) { return reader.getPageFor(url); } } Экземпляр PageIterator может совместно использоваться разными потоками, каждый из которых использует собственный экземпляр PageReader для чтения и обработки страниц, полученных от итератора . 375 376 Приложение А . Многопоточность II Обратите внимание: блок synchronized очень мал . Он содержит только критиче- скую секцию, расположенную глубоко внутри PageIterator . Старайтесь синхро- низировать как можно меньший объем кода . Вычисление производительности в однопоточной модели Выполним некоторые простые вычисления . Для наглядности возьмем следующие показатели: Время ввода/вывода для получения страницы (в среднем): 1 секунда . Время обработки страницы (в среднем): 0,5 секунды . Во время операций ввода/вывода процессор загружен на 0%, а во время об- работки – на 100% . При обработке N страниц в однопоточной модели общее время выполнения со- ставляет 1,5 секунды * N . На рис . А .1 изображен график обработки 13 страниц примерно за 19,5 секунды . Рис . А .1 . Обработка страниц в однопоточной модели Вычисление производительности в многопоточной модели Если страницы могут загружаться в произвольном порядке и обрабатываться независимо друг от друга, то для повышения производительности можно вос- пользоваться многопоточной моделью . Что произойдет, если обработка будет производиться тремя потоками? Сколько страниц удастся обработать за то же время? Как видно из рис . А .2, многопоточное решение позволяет совмещать процессорно- ориентированную обработку страниц с операциями чтения страниц, ориентиро- ванными на ввод/вывод . В идеальном случае это обеспечивало бы полную за- грузку процессора: каждое чтение страницы продолжительностью в одну секунду перекрывается с обработкой двух страниц . Таким образом, многопоточная модель обрабатывает две страницы в секунду, что втрое превышает производительность однопоточной модели . 376 Взаимная блокировка 377 Рис . А .2 . Обработка тремя параллельными потоками Взаимная блокировка Допустим, у нас имеется веб-приложение с двумя общими пулами ресурсов конечного размера: Пул подключений к базе данных для локальной обработки в памяти процесса . Пул подключений MQ к главному хранилищу . В работе приложения используются две операции, создание и обновление: Создание – получение подключений к главному хранилищу и базе данных . Взаимодействие с главным хранилищем и локальное сохранение данных в базе данных процесса . Обновление – получение подключений к базе данных, а затем к главному хранилищу . Чтение данных из базы данных процесса и их последующая пере- дача в главное хранилище . Что произойдет, если количество пользователей превышает размеры пулов? До- пустим, каждый пул содержит десять подключений . Десять пользователей пытаются использовать операцию создания . Они за- хватывают все десять подключений к базе данных . Выполнение каждого потока прерывается после захвата подключения к базе данных, но до захвата подключения к главному хранилищу . Десять пользователей пытаются использовать операцию обновления . Они захватывают все десять подключений к главному хранилищу . Выполнение каждого потока прерывается после захвата подключения к главному храни- лищу, но до захвата подключения к базе данных . 377 378 Приложение А . Многопоточность II Десять потоков, выполняющих операцию создания, ожидают подключений к главному хранилищу, а десять потоков, выполняющих операцию обновле- ния, ожидают подключений к базе данных . Возникает взаимная блокировка . Продолжение работы системы невозможно . На первый взгляд такая ситуация выглядит маловероятной, но кому нужна система, которая гарантированно зависает каждую неделю? Кому захочется отлаживать систему с такими трудновоспроизводимыми симптомами? Когда такие проблемы возникают в эксплуатируемой системе, на их решение уходят целые недели . Типичное «решение» основано на включении отладочных команд для получения дополнительной информации о происходящем . Конечно, отладочные команды достаточно сильно изменяют код, взаимная блокировка возникает в другой си- туации, и на повторение ошибки могут потребоваться целые месяцы 1 Чтобы действительно решить проблему взаимной блокировки, необходимо понять, из-за чего она возникает . Для возникновения взаимной блокировки не- обходимы четыре условия: Взаимное исключение . Блокировка с ожиданием . Отсутствие вытеснения . Циклическое ожидание . Взаимное исключение Взаимное исключение возникает в том случае, когда несколько потоков должны использовать одни и те же ресурсы, и эти ресурсы: не могут использоваться несколькими потоками одновременно; существуют в ограниченном количестве . Типичный пример ресурсов такого рода – подключения к базам данных, откры- тые для записи файлы, блокировки записей, семафоры . Блокировка с ожиданием Когда один поток захватывает ресурс, он не освобождает его до тех пор, пока не захватит все остальные необходимые ресурсы и не завершит свою работу . Отсутствие вытеснения Один поток не может отнимать ресурсы у другого потока . Если поток захватил ресурс, то другой поток сможет получить захваченный ресурс только в одном случае: если первый поток его освободит . 1 Кто-то добавляет отладочный вывод, и проблема «исчезает» . Отладочный код «решил» проблему, поэтому он остается в системе . 378 Взаимная блокировка 379 циклическое ожидание Допустим, имеются два потока T1 и T2 и два ресурса R1 и R2 . Поток T1 захватил R1, поток T2 захватил R2 . Потоку T1 также необходим ресурс R2, а потоку T2 также необходим ресурс R1 . Ситуация выглядит так, как показано на рис . А .3 . Рис . А .3 . Циклическое ожидание Взаимная блокировка возможна только при соблюдении всех четырех условий . Стоит хотя бы одному из них нарушиться, и взаимная блокировка исчезнет . нарушение взаимного исключения Одна из стратегий предотвращения взаимной блокировки основана на предот- вращении состояния взаимного исключения . Использование ресурсов, поддерживающих многопоточный доступ (напри- мер, AtomicInteger ) . Увеличение количества ресурсов, чтобы оно достигло или превосходило ко- личество конкурирующих потоков . Проверка наличия всех свободных ресурсов перед попытками захвата . К сожалению, большинство ресурсов существует в ограниченном количестве и не поддерживает многопоточное использование . Кроме того, второй ресурс нередко определяется только по результатам обработки первого ресурса . Но не огорчайтесь – остаются еще три условия . нарушение блокировки с ожиданием Вы также можете нарушить взаимную блокировку, если откажетесь ждать . Про- веряйте каждый ресурс, прежде чем захватывать его; если какой-либо ресурс за- нят, освободите все захваченные ресурсы и начните все заново . При таком подходе возможны следующие потенциальные проблемы: Истощение – один поток стабильно не может захватить все необходимые ему ресурсы (уникальная комбинация, в которой все ресурсы одновременно оказываются свободными крайне редко) . 379 380 Приложение А . Многопоточность II Обратимая блокировка – несколько потоков «входят в клинч»: все они захва- тывают один ресурс, затем освобождают один ресурс… снова и снова . Такая ситуация особенно вероятна при использовании тривиальных алгоритмов планирования процессорного времени (встроенные системы; простейшие, написанные вручную алгоритмы балансировки потоков) . Обе ситуации могут привести к снижению производительности . Первая ситуация оборачивается недостаточным, а вторая – завышенным и малоэффективным ис- пользованием процессора . Какой бы неэффективной ни казалась эта стратегия, она все же лучше, чем ниче- го . По крайней мере, вы всегда можете реализовать ее, если все остальные меры не дают результата . нарушение отсутствия вытеснения Другая стратегия предотвращения взаимных блокировок основана на том, чтобы потоки могли отнимать ресурсы у других потоков . Обычно вытеснение реали- зуется на основе простого механизма запросов . Когда поток обнаруживает, что ресурс занят, он обращается к владельцу с запросом на его освобождение . Если владелец также ожидает другого ресурса, он освобождает все удерживаемые ресурсы и начинает захватывать их заново . Данное решение сходно с предыдущим, но имеет дополнительное преимущество: поток может ожидать освобождения ресурса . Это снижает количество освобож- дений и повторных захватов . Но учтите, что правильно реализовать управление запросами весьма непросто . нарушение циклического ожидания Это самая распространенная стратегия предотвращения взаимных блокировок . В большинстве систем она не требует ничего, кроме системы простых правил, соблюдаемых всеми сторонами . Вспомните приведенный ранее пример с потоком 1, которому необходимы ресурс 1 и ресурс 2, и потоком 2, которому необходимы ресурс 2 и ресурс 1 . За- ставьте поток 1 и поток 2 выделять ресурсы в постоянном порядке, при котором циклическое ожидание станет невозможным . В более общем виде, если все потоки согласуют единый глобальный порядок и будут захватывать ресурсы только в указанном порядке, то взаимная блоки- ровка станет невозможной . Эта стратегия, как и все остальные, может создавать проблемы: Порядок захвата может не соответствовать порядку использования; таким образом, ресурс, захваченный в начале, может не использоваться до самого конца . В результате ресурсы остаются заблокированными дольше необходи- мого . 380 Тестирование многопоточного кода 381 Соблюдение фиксированного порядка захвата ресурсов возможно не всегда . Если идентификатор второго ресурса определяется на основании операций, выполняемых с первым ресурсом, то эта стратегия становится невозможной . Итак, существует много разных способов предотвращения взаимных блокировок . Одни приводят к истощению потоков, другие – к интенсивному использованию процессора и ухудшению времени отклика . Бесплатный сыр бывает только в мышеловке! Изоляция потокового кода вашего решения упрощает настройку и эксперименты; к тому же это действенный способ сбора информации, необходимой для опреде- ления оптимальных стратегий . тестирование многопоточного кода Как написать тест, демонстрирующий некорректность многопоточного кода? 01: public class ClassWithThreadingProblem { 02: int nextId; 03: 04: public int takeNextId() { 05: return nextId++; 06: } 07:} Тест, доказывающий некорректность, может выглядеть так: Запомнить текущее значение nextId Создать два потока, каждый из которых вызывает takeNextId() по одному разу . Убедиться в том, что значение nextId на 2 больше исходного . Выполнять тест до тех пор, пока в ходе очередного теста nextId не увеличится только на 1 вместо 2 . Код такого теста представлен в листинге А .2 . листинг А .2 . ClassWithThreadingProblemTest.java 01: package example; 02: 03: import static org.junit.Assert.fail; 04: 05: import org.junit.Test; 06: 07: public class ClassWithThreadingProblemTest { 08: @Test 09: public void twoThreadsShouldFailEventually() throws Exception { 10: final ClassWithThreadingProblem classWithThreadingProblem = new ClassWithThreadingProblem(); 11: 12: Runnable runnable = new Runnable() { продолжение 381 382 Приложение А . Многопоточность II листинг А .2 (продолжение) 13: public void run() { 14: classWithThreadingProblem.takeNextId(); 15: } 16: }; 17: 18: for (int i = 0; i < 50000; ++i) { 19: int startingId = classWithThreadingProblem.lastId; 20: int expectedResult = 2 + startingId; 21: 22: Thread t1 = new Thread(runnable); 23: Thread t2 = new Thread(runnable); 24: t1.start(); 25: t2.start(); 26: t1.join(); 27: t2.join(); 28: 29: int endingId = classWithThreadingProblem.lastId; 30: 31: if (endingId != expectedResult) 32: return; 33: } 34: 35: fail("Should have exposed a threading issue but it did not."); 36: } 37: } |