Всем привет! Я работаю в Тинькофф, в проекте «Тинькофф Телефония». Наша основная задача — организация IP-телефонии внутри компании и за ее пределами. В день через нас проходит несколько миллионов звонков. Всей этой кухней занимаются около 10 служб.  В какой-то момент мы столкнулись с проблемой: во время релиза сервисы останавливаются неприлично долго. Нас это печалило, поэтому мы приняли решение прокидывать везде и всюду CancellationToken.  Чуть позже столкнулись с другой проблемой: все наши сервисы начали потихоньку пожирать доступную память. Не так чтобы слишком сильно и быстро, но в целом в перспективе нескольких недель — заметно.  В один прекрасный день терпение лопнуло, и мы с Женей Мишустиным и Кириллом Марковым накинулись на эту проблему: Женя с Кириллом ловили и анализировали дампы, а я занимался поиском причин такого поведения. Особенно отчетливо выделялся один сервис, который запускался по расписанию и выполнял обработку записанных звонков. Так как таких записей довольно много, то их анализ и обработка затягивались на длительное время. А после завершения память освобождалась. При этом нельзя сказать, что внутри создавалось какое-то большое количество объектов для работы. А пару месяцев назад началась беда: один из сервисов, который запускает по расписанию некоторые задачи, внезапно стал неприлично прожорлив! Он мог за довольно короткий промежуток времени захватить 2—5 ГБ. И тут стало ясно, что пришло время стряхнуть пыль с dotMemory и WinDbg!  Спойлер для тех, кому лень читать и разбираться: в CancellationTokenSource нет утечек — есть особенности его использования. Начали снимать дампы, смотреть. Во многих была примерно одинаковая картина: дамп весит около 5 ГБ, а dotMemory упорно показывает размер куч до 1—2 ГБ.   Ну, вроде все нормально, ничего критичного. Но дамп-то огромный! Чем занята остальная память? Думали, что это серверный режим работы GC (описание режимов, настройка). Меняли, ждали — не помогает.  Женя уже долгое время наблюдал за неуправляемой памятью сервисов, и ее размер не давали ему покоя: он мог достигать 8—10 ГБ. Почему она так растет? Узнав о проблеме, наш руководитель поделился опытом: в смежном проекте было подобное — там неуправляемая память была забита строками от сторонней библиотеки. А что же может быть у нас? Мы пользуемся неуправляемым клиентом Oracle и еще одной утилитой, которая тоже может лезть в unmanged-код. Но с ними никаких проблем не было. Мы их не меняли, не обновляли… А вдобавок еще и dotMemory вот так просто не хочет нам показывать содержимое неуправляемой памяти.Настало время другого героя WinDbg. Немного поболтав с Google, я нашел статейку: Investigating issues with Unmanaged Memory. First steps, где по шагам расписывается, как заглянуть в зазеркалье — в неуправляемую память. Почитали и сделали. Картина удивила: Как понимаете, внутри ошметки SQL-запросов, ORA-ошибок и так далее. Напрашивается только один вывод: Каждый раз смотреть таким образом неуправляемую память — ну очень долго и неудобно. Тут Женя вспомнил про книгу Windows Internals, которую не так давно прочел, где рассказывается про VMMap. VMMap позволяет заглянуть в неуправляемую память на живом процессе и посмотреть, что за строки там хранятся. Как я уже говорил, во всех снятых исследуемых снимках размер управляемых куч был невелик. Но ответа на вопрос, почему неуправляемая память не освобождается, мы так и не нашли. Однажды удалось снять дамп, где по куче еще не успел пройтись GC. Сразу внимание привлекли две области памяти: список объектов на 600 МБ и 17 экземпляров CancellationTokenSource на 3,5 ГБ. Что ж, есть два вопроса: Почему столько объектов в списке?   Почему такой огромный CancellationTokenSource?  С первой областью памяти разобрались довольно быстро и просто, но это будет неинтересно читателю, так как касалось бизнеса. А вот что касается второй — тут начинается самое интересное. Давайте попробуем разобраться, что же хранит CancellationTokenSource и как это туда попадает? И начнем, как и полагается, с теории. CancellationTokenSource – это Signals to a CancellationToken that it should be canceled.   Если говорить простым языком, то эта штука создает CancellationToken, который можно использовать для отмены выполнения операции. Обычно мы проверяем состояние токена отмены, и если он отменен, то прерываем выполнение или кидаем исключение. Сам токен перевести в отменённое состояние может его родитель (Source). Все просто.  Но как работает эта связка? Почему в итоге у нас один CancellationTokenSource занял более 3 ГБ? Давайте заглянем внутрь, что же там такое хранится? В самом Source хранится массив CancellationCallbackInfo с информацией о каком-то действии, которое необходимо вызвать при срабатывании отмены. Основной объект, для которого нужно вызвать метод, — это… OracleCommand. Если посмотреть все остальные объекты массива, то все они выглядят одинаково. А что лежит во втором по величине CancellationTokenSource? Все то же самое, только запрос другой. Теперь осталось понять, как какой-то OracleCommand, с которым напрямую никто не работает, попадает в  Вряд ли есть какая-то магия, но пока непонятно. Я решил начать с исследования исходников CancellationTokenSource, а конкретно — найти место, где создается этот самый CancellationCallbackInfo и StateForCallback. Кстати, StateForCallback — это делегат, как видно из двух предыдущих изображений, а значит, _target — это какое-то внутреннее поле, где хранится объект, метод которого и «сохранен» в делегате. Получается, что где-то какой-то метод из OracleCommand передается в качестве делегата. Ищем, где создается CancellationCallbackInfo и StateForCallback. Оказалось — в CancellationTokenSource.InternalRegister. Сам метод InternalRegister вызывается из нескольких перегрузок CancellationToken.Register. Согласно документации, этот метод предназначен для: Registers a delegate that will be called when this CancellationToken is canceled. С этим все ясно, но где происходит регистрация? Ответ на этот вопрос решил искать в лоб — прямыми переходами по реализациям вызываемых методов, куда передается CancellationToken. Мой поиск начался с первого метода, который выходит за пределы нашего кода: Далее следует скучное «протыкивание» по вызовам. Сразу перейду к интересному. В какой-то момент IDE выдала список реализаций метода, но Oracle внутри не было.   Напомню: у нас используется неуправляемый клиент с управляемой оберткой. В списке ничего похожего на слово Oracle нет, ну да ладно. Посмотрим, как сделано у других — например, в Postgre-клиенте: Что, неужели конец? Но ведь ответа на вопрос, как происходит регистрация, я не нашел. Решил поискать вызов метода Register во всех доступных символах и исходниках в проекте. И… Кажется, получилось. Oracle-клиента я все равно не нашел, но нашел очень похожее в Postgre. Скорее же смотреть, что там происходит: Метод довольно большой, но токен отмены используется только в одном месте — в самом начале. То есть мы регистрируем метод отмены «команды». Значит, нечто подобное должно быть и в Oracle-клиенте. Но поиск не давал никакой информации.  Выход один — декомпиляция. После нескольких кликов получил «исходный» код на C#. Но вот опять проблема: внутри — ни слова ни про Task, ни про Async, ни про CancellationToken. То есть клиент оказался абсолютно синхронным (вот это открытие). Однако, если внимательнее поискать, слово Cancel все-таки встречается: Еще пара кликов приводит нас в базовый класс DbCommand и в метод ExecuteDbDataReaderAsync:   И вот она — регистрация! Но почему я не попал сюда в самом начале? Какой же все-таки стек вызова? Точки останова и IDE нам в помощь. Выглядит это примерно так:   Довольно далеко и неявно. Главное, что мы разобрались!   Проверять, отменен ли CancellationToken, и кидать исключение или каким-то образом завершать цикл. Регистрировать свой метод отмены или завершения работы в CancellationToken. Регистрация происходит через передачу делегата, который содержит в себе ссылку на объект. В нашем случае использовался второй способ. В самом объекте OracleCommand хранится SQL-запрос и ответ. Детально работу неуправляемого клиента я не изучал, но наверняка выделяется некоторый участок памяти под ответ сервера БД. Схема работы такая: Создаем CancellationTokenSource. Начинаем цикл обработки записей звонков. Создаем OracleCommand с новым запросом. Передаем CancellationToken OracleCommand при выполнении очередного запроса. OracleCommand регистрирует свой метод Cancel() в CancellationToken, сохраняя ссылку на себя в делегате.   Выполняется запрос, выделяется память под ответ.   Выполняется полезная нагрузка для очередной записи.   Через N итераций завершаем цикл.   Завершаем процесс обработки.   Вызываем завершение CancellationTokenSource.   Вызывается отмена для КАЖДОЙ регистрации OracleCommand.   Или вот еще пример «бесконечного» цикла, где каждые 15 секунд из БД достаются записи и проверяются на актуальность:   То есть в CancellationTokenSource будут храниться все объекты, которые зарегистрировали свой собственный метод отмены/завершения, до окончания работы с этим CancellationTokenSource.   Не используйте ОДИН CancellationTokenSource, если необходимо совершить гарантированно короткие операции большое количество раз, например в цикле. Используйте CancellationTokenSource для детерминированных операций, которые должны закончиться в какое-то определенное время. А если очень нужно отказаться от п. 1, то используйте для каждой итерации свой CancellationTokenSource и связывайте его с основным через CreateLinkedTokenSource. Неуправляемый Oracle Client — полностью синхронный внутри. Надеюсь, было не очень скучно ;) Разработчик