Плохо документированные особенности Linux

    Привздохнув, произнесла:
    «Как же долго я спала!»
    image Когда-то, впервые встретив Unix, я был очарован логической стройностью и завершенностью системы. Несколько лет после этого я яростно изучал устройство ядра и системные вызовы, читая все что удавалось достать. Понемногу мое увлечение сошло на нет, нашлись более насущные дела и вот, начиная с какого-то времени, я стал обнаруживать то одну то другую фичу про которые я раньше не знал. Процесс естественный, однако слишком часто такие казусы обьединяет одно — отсутствие авторитетного источника документации. Часто ответ находится в виде третьего сверху комментария на stackoverflow, часто приходится сводить вместе два-три источника чтобы получить ответ на именно тот вопрос который задавал. Я хочу привести здесь небольшую коллекцию таких плохо документированных особенностей. Ни одна из них не нова, некоторые даже очень не новы, но на каждую я убил в свое время несколько часов и часто до сих пор не знаю систематического описания.

    Все примеры относятся к Linux, хотя многие из них справедливы для других *nix систем, я просто взял за основу самую активно развивающуюся ОС, к тому же ту, которая у меня перед глазами и где я могу быстро проверить предлагаемый код.

    Обратите внимание, в заголовке я написал «плохо документированные» а не «малоизвестные», поэтому тех кто в курсе прошу выкладывать в комментариях ссылки на членораздельную документацию, я с удовольствием добавлю в конце список.

    Возвращается ли освобожденная память обратно в ОС?


    Этот вопрос, заданный вполне мною уважаемым коллегой, послужил спусковым крючком для этой публикации. Целых полчаса после этого я смешивал его с грязью и обзывал сравнительными эпитетами, обьясняя что еще классики учили — память в Unix выделяется через системный вызов sbrk(), который просто увеличивает верхний лимит доступных адресов; выделяется обычно большими кусками; что конечно технически возможно понизить лимит и вернуть память в ОС для других процессов, однако для аллокатора очень накладно отслеживать все используемые и неиспользуемые фрагменты, поэтому возвращение памяти не предусмотрено by design. Этот классический механизм прекрасно работает в большинстве случаев, исключение — сервер часами/месяцами тихо сидящий без дела, вдруг запрашивающий много страниц для обработки какого-то события и снова тихо засыпающий (но в этом случае выручает своп). После чего, удовлетворив свое ЧСВ, я как честный человек пошел подтвердить в интернетах свое мнение и с удивлением обнаружил, что Linux начиная с 2.4 может использовать как sbrk() так и mmap() для выделения памяти, в зависимости от запрошенного размера. Причем память аллоцированная через mmap() вполне себе возвращается в ОС после вызова free()/delete. После такого удара мне оставалось только одно два — смиренно извиниться и выяснить чему же точно равен этот таинственный предел. Поскольку никакой информации так и не нашел, пришлось мерить руками. Оказалось, на моей системе (3.13.0) — всего 120 байт. Код линейки для желающих перемерить — здесь.

    Каков минимальный интервал который процесс/поток может проспать?


    Тот же самый Морис Бах учил: планировщик (scheduler) процессов в ядре активируется по любому прерыванию; получив управление, планировщик проходит по списку спящих процессов и переводит те из них которые проснулись (получили запрошенные данные из файла или сокета, истек интервал sleep() и т.д.) в список «ready to run», после чего выходит из прерывания обратно в текущий процесс. Когда происходит прерывание системного таймера, которое случалось когда-то раз в 100 мс, потом, по увеличении скорости CPU, раз в 10 мс, планировщик ставит текущий процесс в конец списка «ready to run» и запускает первый процесс из начала этого списка. Таким образом, если я вызвал sleep(0) или вообще заснул на мгновение по любому поводу, так что мой процесс был переставлен из списка «ready to run» в список «preempted», у него нет никаких шансов заработать снова раньше чем через 10 мс, даже если он вообще один в системе. В принципе, ядро можно перестроить уменьшив этот интервал, однако это вызывает неоправданно большие расходы CPU, так что это не выход. Это хорошо известное ограничение долгие годы отравляло жизнь разработчикам быстро-реагирующих систем, именно оно в значительной степени стимулировало разработку real-time systems и неблокирующих (lockfree) алгоритмов.

    И вот как-то я повторил этот эксперимент (меня на самом деле интересовали более тонкие моменты типа распределения вероятностей) и вдруг увидел что процесс просыпается после sleep(0) через 40 мкс, в 250 раз быстрее. То же самое после вызовов yield(), std::mutex::lock() и всех прочих блокирующих вызовов. Что же происходит?!

    Поиск довольно быстро привел к Completely Fair Scheduler введенному начиная с 2.6.23, однако я долго не мог понять как именно этот механизм приводит к такому быстрому переключению. Как я выяснил в конце-концов, отличие заключается именно в самом алгоритме default scheduler class, того под которым запускаются все процессы по умолчанию. В отличие от классической реализации, в этой каждый работающий процесс/поток имеет динамический приоритет, так что у работающего процесса приоритет постепенно понижается относительно других ожидающих исполнения. Таким образом, планировщик может принять решение о запуске другого процесса немедленно, не ожидая окончания фиксированного интервала, а сам алгоритм перебора процессов теперь О(1), существенно легче и может выполнятся чаще.

    Это изменение ведет к удивительно далеко идущим последствиям, фактически зазор между real-time и обычной системой почти исчез, предлагаемая задержка в 40 микросекунд реально достаточно мала для большинства прикладных задач, то же самое можно сказать про неблокирующие алгоритмы — классические блокирующие структуры данных на мьютексах стали очень даже конкурентноспособны.

    А что такое вообще эти классы планировщика (scheduling policies)?


    Эта тема более-менее описана, повторяться не буду, и тем не менее, откроем одну и вторую авторитетные книги на соответствующей странице и сравним между собой. Налицо почти дословное в некоторых местах повторение друг друга, а так же некоторые расхождения с тем что говорит man -s2 sched_setscheduler. Однако симптом.

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

    iBolit# ./sche -d0 -i0 -b0 -f1 -r2 -f3 -i0 -i0 -i0 -d0
    6 SCHED_FIFO[3]
    5 SCHED_RR[2]
    4 SCHED_FIFO[1]
    1 SCHED_OTHER[0]
    2 SCHED_IDLE[0]
    3 SCHED_BATCH[0]
    7 SCHED_IDLE[0]
    8 SCHED_IDLE[0]
    9 SCHED_IDLE[0]
    10 SCHED_OTHER[0]
    

    Число в начале строки показывает порядок в котором потоки создавались. Как видим два приоритетных класса SCHED_FIFO и SCHED_RR всегда имеют приоритет перед тремя обычными классами SCHED_OTHER, SCHED_BATCH и SCHED_IDLE, и между собой ранжируются строго по приоритету, то что и требовалось. Но вот например то, что все три юзер-класса на старте равноправны вообще нигде не упомянуто, даже SCHED_IDLE, который намного поражен в правах по сравнению с дефолтным SCHED_OTHER, запускается вперед него если стоит в очереди на мьютексе первым. Ну по крайней мере в целом все работает, а вот
    у Solaris в этом месте вообще дырка
    Несколько лет назад я прогнал этот тест под Solaris и обнаружил что приоритеты потоков полностью игнорируются, потоки пробуждаются в совершенно произвольном порядке. Я тогда связался с тех.поддержкой Sun, но получил на удивление невнятный и бессодержательный ответ (до этого они охотно с нами сотрудничали). Через две недели Sun не стало. Я искренне надеюсь что не мой запрос послужил этому причиной.

    Для тех кто хочет сам пограться с приоритетами и классами, исходный код там же.

    Задержанные TCP пакеты


    Если предыдущие примеры можно считать приятным сюрпризом, то вот этот вот приятным назвать трудно.
    История началась несколько лет назад когда мы вдруг обнаружили что один из наших серверов, посылающий клиентам непрерывный поток данных, испытывает периодические задержки в 40 милисекунд. Это случалось нечасто, однако позволить себе такую роскошь мы не могли, поэтому был исполнен ритуальный танец со сниффером и последующим анализом. Внимание, при обсуждении в интернете эту проблему как правило связывают с алгоритмом Нагла (Nagle algorithm), неверно, по нашим результатам проблема возникает на Linux при взаимодействии delayed ACK и slow start. Давайте вспомним другого классика, Ричарда Стивенса, чтобы освежить память.
    delayed ACK — это алгоритм задерживающий отправку ACK на полученный пакет на несколько десятков милисекунд в расчете что немедленно будет послан ответный пакет и ACK можно будет встроить в него с очевидной целью — уменьшить трафик пустых датаграм по сети. Этот механизм работает в интерактивной TCP сессии и в 1994 году, когда вышла TCP/IP Illustrated, был уже стандартной частью TCP/IP стека. Что важно для понимания дальнейшего, задержка может быть прервана в частности прибытием следующего пакета данных, в этом случае кумулятивный ACK на обе датаграммы отправляется немедленно.
    slow start — не менее старый алгоритм призванный защитить промежуточные маршрутизаторы от чересчур агрессивного источника. Посылающая сторона в начале сессии может послать только один пакет и должна дождаться ACK от получателя, после этого может послать два, четыре и т.д., пока не упрется в другие механизмы регулирования. Этот механизм очевидно работает в случае обьемного трафика и, что существенно, он включается в начале сессии и после каждой вынужденной ретрансляции потерянной датаграммы.
    TCP сессии можно разделить на два больших класса — интерактивные (типа telnet) и обьемные (bulk traffic, типа ftp). Легко заметить что требования к регулирующим трафик алгоритмам в этих случаях часто противоположны, в частности требования «задержать ACK» и «дождаться ACK» очевидно противоречат друг другу. В случае стабильной TCP сессии спасает условие упомянутое выше — получение следующего пакета прерывает задержку и ACK на оба сегмента высылается не дожидаясь попутного пакета с данными. Однако, если вдруг один из пакетов теряется, посылающая сторона немедленно инициирует slow start — посылает одну датаграмму и ждет ответа, принимающая сторона получает одну датаграмму и задерживает ACK, поскольку данные в ответ не посылаются, весь обмен подвисает на 40 мс. Voilà.
    Эффект возникает именно в Linux — Linux TCP соединениях, в других системах я такого не видел, похоже что-то у них в реализации. И как с этим бороться? Ну, в принципе Linux предлагает (нестандартную) опцию TCP_QUICKACK, которая отключает delayed ACK, однако опция эта нестойкая, отключается автоматически, так что взводить флажок приходится перед каждым read()/write(). Есть еще /proc/sys/net/ipv4, в частности /proc/sys/net/ipv4/tcp_low_latency, но вот делает ли она то что я подозреваю она должна делать — неизвестно. Кроме того этот флажок будет относиться ко всем TCP соединениям на данной машине, нехорошо.
    Какие будут предложения?

    Из тьмы веков


    И напоследок, самый первый казус в истории Linux, просто для полноты картины.
    С самого начала в Linux присутствовал нестандартный системный вызов — clone(). Он работает так же как и fork(), то есть создает копию текущего процесса, но при этом адресное пространство остается в совместном пользовании. Нетрудно догадаться для чего он был придуман и действительно, это изящное решение сразу выдвинуло Linux в первые ряды среди ОС по реализации многопоточности. Однако всегда есть один нюанс…

    Дело в том что при клонировании процесса также клонируются все файловые дескрипторы, в том числе и сокеты. Если раньше была отработанная схема: открывается сокет, передается в другие потоки, все дружно сотрудничают посылая и получая данные, один из потоков решает закрыть сокет, все другие сразу видят что сокет закрылся, на другом конце соединения (в случае TCP) тоже видят что сокет закрыт; то что получается теперь? Если один из потоков решает закрыть свой сокет, другие потоки об этом ничего не знают, поскольку они на самом деле отдельные процессы и у них свои собственные копии этого сокета, и продолжают работать. Более того, другой конец соединения тоже считает соединение открытым. Дело прошлое, но когда-то это нововведение поломало паттерн многим сетевым программистам, да и кода пришлось переписать под Linux изрядно.

    Литература


    1. Maurice J. Bach. The Design of the UNIX Operating System.
    2. Robert Love. Linux Kernel Development
    3. Daniel P. Bovet, Marco Cesati. Understanding the Linux Kernel
    4. Richard Stevens. TCP/IP Illustrated, Volume 1: The Protocols
    5. Richard Stevens. Unix Network Programming
    6. Richard Stevens. Advanced Programming in the UNIX Environment
    7. Uresh Vahalia. UNIX Internals: The New Frontiers

    Здесь могла бы быть ваша ссылка на затронутые темы


    Первые ласточки:


    А еще мне действительно интересно, сколько же все таки я проспал и насколько отстал от жизни. Позвольте включить небольшой опрос.
    Насколько малоизвестны затронутые вопросы?

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

    Поделиться публикацией
    Реклама помогает поддерживать и развивать наши сервисы

    Подробнее
    Реклама
    Комментарии 104
    • +2
      По поводу mmap есть нюанс:
             M_MMAP_THRESHOLD
                    For allocations greater than or equal to the limit specified
                    (in bytes) by M_MMAP_THRESHOLD that can't be satisfied from
                    the free list, the memory-allocation functions employ mmap(2)
                    instead of increasing the program break using sbrk(2).
      

      Причём сама glibc решает, когда mmap-ить, а когда не следует.
      • +1
        Детали этого выбора надо читать в glibc malloc.c
        Там с первого взгляда довольно сложная логика (порог выбирается динамически?), по умолчанию — 128 КиБ.
        Даже странно, что автор пытался искать это в ядре Linux.

        • +4
          Именно, чтобы не дёргать по каждому поводу ядро для выделения куска памяти (скажем, создание множества объектов — счёт идёт на байты), этот механизм отдаётся на откуп библиотеке libc. Недаром существует целая куча кустарных библиотек выделения памяти. Даже слов не хватает их все описать:) Кстати разница между этими всеми навороченными lockless-библиотеками в Linux — жалкие проценты как раз по причине, озвученной автором статьи.
        • +4
          На всю ветку сразу: пост как раз об этом — где документация? Точнее, где точка входа в документацию?
          Если я слышал про mallopt(), я знаю хотя бы с чего начать. А если нет? Исходники конечно первоисточник, но никак не замена книгам типа тех что в списке литературы.
          И еще, я употребляю слова «система», «ОС», «ядро» в несколько расширенном смысле, как весь набор инструментов используемых прикладным программистом из коробки, glibc сюда естественно тоже относится.
          • +5
            man malloc
            

            NOTES
                   Normally, malloc() allocates memory from the heap, and adjusts the size
                   of the heap as required, using sbrk(2).  When allocating blocks of mem-
                   ory larger than MMAP_THRESHOLD bytes, the glibc malloc() implementation
                   allocates  the  memory  as  a  private anonymous mapping using mmap(2).
                   MMAP_THRESHOLD is 128 kB by  default,  but  is  adjustable  using  mal-
                   lopt(3).   Allocations  performed  using  mmap(2) are unaffected by the
                   RLIMIT_DATA resource limit (see getrlimit(2)).
            

            Если что-то непонятно — смотрим SEE ALSO и ходим по ссылкам. Если что-то плохо описано — contributions are welcome:)
      • 0
        Когда-то я уже устраивал спор на тему, поэтому напишу и сюда. Если программа выделяет 128МБ (~много МБ) памяти, будет ли при этом выделено много страниц по 4кБ, или не так много страниц по 4МБ? И почему?
        • 0
          а разве может в одной системе одновременно существовать страницы по 4Кб и 4Мб — это же определяется при инициализации защищенного режима и страницы есть либо такие либо такие...( PSE-36 для 4Мб вроде зовут под x86, если память не изменяет)
          • 0
            Таки может:
            Enabling PSE (by setting bit 4, PSE, of the system register CR4) changes this scheme. The entries in the page directory have an additional flag, in bit 7, named PS (for Page Size). This flag was ignored without PSE, but now, the page directory entry with PS set to 1 does not point to a page table, but to a single large 4 MiB page. The page directory entry with PS set to 0 behaves as without PSE.
            • 0
              че-т не пойму, но тогда же появляется дыра в схеме трансляции адресов, блок 4Кб по своему индексу должен был бы идти за предыдущим, а т.к. у нас вставлена тут 4 Мб страница, то индексная адресация ломается…

              странно, надо почитать, видимо что-то я упустил…
              • 0
                Страница 4МБ будет вставлена вместо таблицы страниц второго уровня, т.е. вместо 1024 страниц по 4кБ. 1024*4кБ это как раз 4МБ.
                • 0
                  Linux не использует PSE.
                • 0
                  да посмотрел, Вы правы, не заметил (не приходилось использовать 4 Мб — поэтому не вчитывался)…
                  Спасибо, пригодится)
            • 0
              В одной системе — конечно может. Размер страницы — это свойство процесса.
              • 0
                Бывает ещё transparent hugepages. Тогда процесс может думать, что он работает с 4k страницами, а ему выделяют 2M и дробят потом.
              • 0
                Huge pages называется. Могут, и сейчас идёт большой срачик работа над тем, что делать, если huge page надо порезать на маленькие.
                • 0
                  Особенно забавно, если на одном хосте нужно держать и БД (для которых рекомендуют отключать THP), и приложения. Например, в контексте контейнерной виртуализации/разделения ресурсов.
              • +2
                Начиная с версии ядра 2.6.38 (14 марта 2011) реализованы Transparent Hugepages (THP, www.kernel.org/doc/Documentation/vm/transhuge.txt, lwn.net/Articles/423584/). При установке «echo always > /sys/kernel/mm/transparent_hugepage/enabled» выделение большого количества памяти производится на больших страницах (при условии выравнивания и наличия свободных больших страниц; в Intel большие страницы обычно по 2 МБ с выравниванием на 2 МБ). «echo madvise >/sys/kernel/mm/transparent_hugepage/enabled» отключает автоматическое выделение больших страниц, для их использования требуется вызов «madvise(MADV_HUGEPAGE)». «echo never» отключит механизм THP.

                Статистика работы THP доступна в /proc/meminfo (AnonHugePages — количество используемых на данный момент больших страниц в системе), /proc/PID/smaps (AnonHugePages для отдельных приложений), счетчики thp_* в /proc/vmstat (и compact_* для статистики по дефрагментатору памяти — system uses memory compaction to copy data around memory to free a huge page for use).
                • 0
                  О как. Благодарю за информацию.
              • +12
                Только не mks, а µs или на самый крайний случай us.
                • +1
                  Спасибо за список литературы, заказал себе пару книг!
                  • 0
                    Это все абсолютная классика. Все переведено кстати.
                • +1
                  > Задержанные TCP пакеты
                  вот очень бы хотелось услышать решение таких проблем…
                  • 0
                    У нас была немного похожая проблема: отправляли заголовок пакета данных, а затем тело, причём тело по размеру сравнимо с заголовком. На принимающей стороне крутится epoll, и код, который сначала вычитывал заголовок, потом по нему смотрел, сколько дальше должно быть данных, и вычитывал уже их. И мы регулярно получали паузы по 40мс между заголовком и данными, потому что прочитать заголовок успевали раньше, чем отправится тело. Решили проблему установкой флага TCP_CORK перед отправкой заголовка.
                    • 0
                      Абсолютного решения нет, будем считать первым шагом к решению то что мы о проблеме знаем.
                      • +3
                        net.ipv4.tcp_slow_start_after_idle = 0 не поможет в этом случае?
                        • +1
                          Возможно и поможет, но мне например чтобы полностью убедится пришлось бы выкатывать это в продакшн. Ну его нахрен, знаю я чем такие игры кончаются. А главное — этот ключик подействует на все процессы на машине, а там не только мои и системные к сожалению…
                          За вариант спасибо
                        • 0
                          Reducing the TCP delayed ack timeout ???

                          Алсо: grep «define.*TCP_DELACK_M[AINX].*((» /usr/src/linux/include/net/tcp.h. If changing it breaks whatever I don't want to know. :-))))
                          • 0
                            Да, /proc/sys/net/ipv4/tcp_delack_min это вариант, опять же плохо то что systemwide.
                            А переделывать kernel — это уж извините полный хардкор, во многих случаях заказчик продукта — сторонняя организация, которая диктует под какую систему и на какой версии все должно работать. Втюхать им самодельный kernel это уж какой-то беспредельно изысканный маркетинг получается.
                            • +1
                              Прочитал, что /proc/sys/net/ipv4/tcp_delack_min — уникален для рэдхэта.
                              • 0
                                Да, у них бывает. Типа /sys/kernel/mm/redhat_transparent_hugepage/
                                • 0
                                  Да, действительно. Жаль
                        • 0
                          В последнем абзаце речь о «Posix Threads»?
                          А то, не очень ясно.
                          • 0
                            В частности о них, pthreads определяет API а не реализацию. На Linux они естественно построены поверх clone()
                            • 0
                              У вызова clone есть куча флагов, которые указывают то, что клонировать, а что нет. Мне кажется, что при вызове pthread_create в clone не передаётся флаг CLONE_FILES, а вот при вызове fork — передается.

                              Если вы хотите ещё прекрасных загадок, которые вам сломают голову, то можно, например, погрузиться в то, что происходит, когда многопоточному приложению делают fork() :-)
                              • 0
                                Там как раз более-менее просто (и документировано). fork() закрывает все потоки кроме того из которого был вызван, это как раз постулировано стандартом с самого начала мультипоточности.
                                • 0
                                  Это да и фактически надо делать exec после этого, иначе состояния объектов синхронизации, которые клонировались после форка, в непредсказуемом состоянии. Но если ты никогда этим вопросом не заморачивался, то вопросов будет много.
                          • 0
                            А на русском про ядерное программирование Линукса что-нибудь выходило?
                            • +2
                              Например Роберт Лав — Ядро Linux. Описание процесса разработки
                            • +1
                              Поскольку никакой информации так и не нашел, пришлось мерить руками. Оказалось, на моей системе (3.13.0) — всего 120 байт

                              Это, мягко говоря, сомнительно, потому что единица выделения с помощью mmap — 1 страница, что сильно больше 120 байт
                              • 0
                                Правильно сомневаетесь. Как выяснилось, sbrk() под Linux тоже может возвращать память в систему если удаляется последний выделенный сегмент, и именно этот порог составляет 120 байт. Если в конце аллоцировать еще байт, порог возвращения — 128К, прямо как в мануалах обещано. Тоже между прочим малоизвестная информация.
                                Я добавил опцию -t к тестовой программе исключающую тримминг памяти.
                              • +7
                                Позвольте мне указать на некоторые неточности в статье, которые также повторяются в комментариях.

                                Выделение памяти. free() действительно возвращает память в систему, но не во всех случаях, и уж тем более про это можно забыть если память сильно фрагментирована. Более подробно (с нужными ссылками на документацию) это описано здесь: Linux: Native Memory Fragmentation and Process Size Growth
                                Unlike certain Java garbage collectors, glibc malloc does not have a feature of heap compaction. Glibc malloc does have a feature of trimming (M_TRIM_THRESHOLD); however, this only occurs with contiguous free space at the top of a heap, which is unlikely when a heap is fragmented.

                                Если есть проблемы с памятью, то лучше переключиться на другие аллокаторы, например, jemalloc — он используется в Mozilla, Facebook, во многих высокопроизводительных и встроенных системах.

                                clone (системный вызов sys_clone). К сожалению, здесь очень много неточностей:
                                • fork() не является подобием clone(), это обертка над ним (man 2 fork: Since version 2.3.3, rather than invoking the kernel's fork() system call, the glibc fork() wrapper that is provided as part of the NPTL threading implementation invokes clone(2) with flags that provide the same effect as the traditional system call.)
                                • clone() не делает адресное пространство общим, это все конфигурируется опциями (man 2 clone: If CLONE_VM is not set, the child process runs in a separate copy of the memory space of the calling process at the time of clone().)
                                • Это не упоминается в статье, но был вопрос в комментариях: pthread использует clone (man 7 pthreads: Both threading implementations employ the Linux clone(2) system call.) Но в общем случае для реализации многопоточности хватит и прямого использования clone (Minimalistic Linux threading)


                                В целом документация есть на все, но это надо знать где смотреть. Опыт работы над встроенными системами научил меня всегда смотреть на документацию API, даже такую элементарную как malloc()/free(). Вот, например, из документации fclose (казалось бы, какие тут могут быть сюрпризы, по крайней мере, с позиции Junior/Intermediate разработчика): Note that fclose() only flushes the user-space buffers provided by the C library. To ensure that the data is physically stored on disk the kernel buffers must be flushed too, for example, with sync(2) or fsync(2).
                                • 0
                                  Да, все правильно.
                                  В ответ могу только заметить что я например знал о fclose()/fsync() с первых дней (это не Линуксная фича — общеюниксная, и ни в коем случае не баг) и никогда бы не подумал включать ее в список малоизвестных фактов.
                                  Похоже, у каждого есть свой набор любимых малоизвестных фич.
                                • +2
                                  Для меня самым неприятным сюрпризом за время моей работы с Линуксом стало то, что я не могу сказать, сколько памяти доступно в системе для выделения. free (и шаманство вокруг /proc/meminfo) даёт апроксимацию первого порядка, но понять, «где память», или «будет OOM после выделения ещё 100Мб памяти или нет» невозможно.

                                  В лабораторных условиях можно. В продакшене (с всякими обширными shmem, tmpfs и т.д.) — нет, потому что часть памяти в cached оказывается невытесняемой.
                                  • 0
                                    А вот free обновили, и теперь он дает такой вывод:
                                    % free -m
                                                  total        used        free      shared  buff/cache   available
                                    Mem:           7871        3389         963         361        3518        3820
                                    Swap:             0           0           0
                                    

                                    Теперь учитывает tmpfs, между прочим!
                                  • +3
                                    Очень вредная статья. Устаревшая информация приправленная домыслами и догадками.

                                    По существу, к прокомментировавшим выше, могу добавить, что флаг TCP_QUICKACK придуман совсем для другого, а именно: задержать отсылку ACK'a если знаем, что потом будем слать данные. По сути убрать отправку пустого пакета с флагом ACK если уверены, что так нужно. К примеру, после тройного рукопожатия в http сессии мы знаем, что пошлём данные от клиента, так что можем придержать последний ACK и отправить его с телом запроса. А у вас скорее всего был включён net.ipv4.tcp_slow_start_after_idle.

                                    По поводу документации и недокументированности не согласен. Документация в манах и в списках рассылки. Код написан довольно понятно. Вы видели лучше документированные системы которые активно разрабатываются?

                                    З.Ы. Вот хорошая книга про внутренности сетевого стека Linux (местами гик порн): www.amazon.com/TCP-Architecture-Design-Implementation-Linux/dp/0470147733
                                    • 0
                                      Отсылка ACK задерживается и так, согласно встроенному в стек delayed ACK алгоритму. TCP_QUICKACK нужен именно для того чтобы этот алгоритм отменить на время. man -s7 tcp:
                                      TCP_QUICKACK (since Linux 2.4.4)
                                      Enable quickack mode if set or disable quickack mode if cleared.
                                      In quickack mode, acks are sent immediately, rather than delayed
                                      if needed in accordance to normal TCP operation.

                                      За книгу спасибо, будет первой в списке
                                    • 0
                                      So low
                                      • +1
                                        Про потоки и дескрипторы — не верю.

                                        Проверка:
                                        int main()
                                        {
                                            int fd = ::open("/dev/zero", O_RDONLY);
                                            char buf[2];
                                            cout << ::read(fd, buf, 2) << endl;
                                            std::thread([&]() { ::close(fd); }).join();
                                            cout << ::read(fd, buf, 2) << endl;
                                            return 0;
                                        }
                                        

                                        Выводит:
                                        2
                                        -1


                                        Т.е. дескриптор текущего потока, закрытый в другом потоке, закрыт и в текущем.

                                        • 0
                                          Так вы про потоки, а в статье про процессы. Повторите тоже самое, только с fork()
                                          • 0
                                            Нет. В статье именно про потоки.
                                            Если один из потоков решает закрыть свой сокет, другие потоки об этом ничего не знают, поскольку они на самом деле отдельные процессы и у них свои собственные копии этого сокета, и продолжают работать.

                                            И процитирванное совершенно не соответствует действительности.
                                            • 0
                                              Хм, действительно, прочитал не внимательно. Ок.
                                              Подозреваю, что сейчас это поведение регулируется флагами вызова clone(). Надо почитать…
                                          • 0
                                            Попробуйте то же самое с сокетами:
                                            void reader(int fd)
                                            {
                                            	char buf[2];
                                            	auto l=read(fd, buf, sizeof(buf));
                                            	std::cout<<l<<std::endl;
                                            	if(l > 0) close(fd);
                                            }
                                            
                                            int s[2];
                                            socketpair(AF_LOCAL, SOCK_STREAM, 0, s)
                                            
                                            char buf[2];
                                            write(s[1], buf, sizeof buf);
                                            
                                            std::thread t1{[=]{ reader(s[0]); }};
                                            std::thread t2{[=]{ reader(s[0]); }};
                                            t1.join();
                                            t2.join();
                                            

                                            выводит 2 и подвисает как и описано в посте.
                                            Я не знал что простые файлы ведут себя по другому, спасибо. Надо будет выяснить что там внутри происходит.

                                            • 0
                                              Он подвисает не потому что закрытие сокета в одном из потоков не видно в другом потоке, а потому что конкуретные операции из разных потоков на одном сокете в линуксе сериализуются мьютексом, и сама операция read достаточно длительная, чтобы оба потока успели вызвать read до того как первый из них вызовет close.

                                              Уберите конкуренцию потоков и увидите что с сокетами все точно также как и с файлами:
                                                  std::thread t1{[=]{ reader(s[0]); }};
                                                  t1.join();
                                                  std::thread t2{[=]{ reader(s[0]); }};
                                                  t2.join();
                                              
                                              • 0
                                                del
                                                • 0
                                                  У-у как интересно. Получается сокеты синхронизируются некоей внешней силой (ядром?) в частности std::thread::join() однозначно синхронизирует состояния сокетов. Однако, если поток успел войти в read(), он никогда не увидит нового состояния и подвиснет. Более того, «настоящий» сокет тоже остается открытым, поскольку другой конец соединения продолжает его видеть. Судя по косвенным признакам это относится так же и к простым дескрипторам, просто там такая ситуация никогда не возникает.
                                                  • 0
                                                    Да не клонируются сокеты/файлы для потоков.
                                                    Это один сокет. А то что вы наблюдаете — обычный race condition.
                                                    Вообще насколько я помню нигде не гарантируется что операции над дескрипторами потокобезопасны.
                                                    Так что так как у вас в первом примере делать вообще нельзя.
                                                    • 0
                                                      А пруф где?
                                                      Вот мое утверждение #1: Если в Linux один поток висит в read() на сокете, а в другом потоке этот сокет закрывается, то ни первый поток, ни сокет на другом конце никогда об этом не узнают. Проверяется непосредственно.
                                                      утверждение #2: во всех не-Linux'ах в такой ситуации второй поток и другой конец видят закрытие сокета, проверяется.
                                                      Далее идут мои домыслы, однако домыслы обоснованные.
                                                      — при форке процесса сокеты клонируются
                                                      — поведение при клонировании сокета идентично тому что мы наблюдаем
                                                      — потоки под Linux создаются через clone()
                                                      => можно предположить что clone() подобно fork() тоже клонирует сокеты, внутренне непротиворечиво.
                                                      race condition естественно присутствует, но он существует параллельно клонированию, это два несвязанных механизма
                                                      • 0
                                                        А пруф где?

                                                        Пруф я уже приводил:
                                                            std::thread t1{[=]{ reader(s[0]); }};
                                                            t1.join();
                                                            std::thread t2{[=]{ reader(s[0]); }};
                                                            t2.join();
                                                        


                                                        Если бы сокет был склонирован, то данный код подвисал бы на втором read().
                                                        • 0
                                                          Это не пруф, sorry. Мы уже показали что при вызове join() состояния сокетов синхронизируются.
                                                          Факт то что поток заблокировавшийся в read() эту синхронизацию никогда не увидит.
                                                          • +1
                                                            Мы уже показали что при вызове join() состояния сокетов синхронизируются.

                                                            Все что мой код выше показывает — это то что если сокеты применять потокобезопасно (как и следует), то никаких таких «эффектов», которые вы себе придумали — нет.
                                                            А когда нет потокобезопасности (как в вашем первом примере) — это обычный undefined behaviour. Утверждать что либо для такого кода бессмысленно.
                                                    • +2
                                                      Что процессы/потоки используют совместно, а что нет, управляется отдельными флагами вызова clone.
                                                      Из man clone:

                                                             CLONE_FILES (since Linux 2.0)
                                                                    If  CLONE_FILES is set, the calling process and the child process share the same file descriptor table.
                                                                    Any file descriptor created by the calling process or by the child process is also valid in  the  other
                                                                    process.   Similarly, if one of the processes closes a file descriptor, or changes its associated flags
                                                                    (using the fcntl(2) F_SETFD operation), the other process is also affected.
                                                      
                                                                    If CLONE_FILES is not set, the child process inherits a copy of all  file  descriptors  opened  in  the
                                                                    calling  process  at  the  time of clone().  (The duplicated file descriptors in the child refer to the
                                                                    same open file descriptions (see  open(2))  as  the  corresponding  file  descriptors  in  the  calling
                                                                    process.)   Subsequent operations that open or close file descriptors, or change file descriptor flags,
                                                                    performed by either the calling process or the child process do not affect the other process.
                                                      


                                                      Для примера, в glibc в функции create_thread используестся такой набор флагов
                                                      const int clone_flags = (CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SYSVSEM
                                                          | CLONE_SIGHAND | CLONE_THREAD
                                                          | CLONE_SETTLS | CLONE_PARENT_SETTID
                                                          | CLONE_CHILD_CLEARTID
                                                          | 0);
                                                      

                                                      а обычный fork реализуется как вызов clone без каких-либо флагов.
                                                      • 0
                                                        Замечательно, по моему нас всех скоро осветит свет истинного понимания. Не будь я так занят ленив, сам бы мог найти. А можете обьяснить как работает код выше по ветке? вот этот
                                                        Почему один поток закрывает сокет а второй этого не видит?
                                                        • +1
                                                          Я, в свое время, даже тест написал для отлова этого эффекта tty системы. Теперь ваша очередь для сокет.
                                                          lkml.org/lkml/2012/12/18/368
                                                          Смысл в следующем:
                                                          Если один поток находится в системном вызове read, например, то второй, может войти в системный вызов close (все это для одного и того же последнего, открытого дескриптора файла). По хорошему, второй должен пометить дескриптор как закрытый, для предотвращения дальнейших попыток чтения/записи, дождаться завершения, а лучше прервать, начатый другим потоком read, и вернутся нормально в user space после read, но как обычно есть нюансы. Бывает, как было с tty, close отработал, а read все пытается читать в уже освобожденную память закрытого файла. В вашем случае, видимо, что-то помягче.
                                                          • 0
                                                            Потому что у вас в коде ошибка. Каждый поток читает по 2 байта, но в сокете всего 2. Соответственно, второй read, если он начался до того, как первый поток закрыл сокет, будет ждать своих 2 байта вечно. Если изменить ваш код так, чтобы каждый поток читал по одному байту, то все происходит так как ожидается: либо оба получают по 1 байту, либо второй read видит уже закрытый сокет.

                                                            Код
                                                            #include <thread>
                                                            #include <unistd.h>
                                                            #include <iostream>
                                                            #include <mutex>
                                                            #include <sys/types.h>
                                                            #include <sys/socket.h>
                                                            
                                                            void reader(int t_id, int fd)
                                                            {
                                                                char buf[2];
                                                                std::cout << t_id << ": Started" << std::endl;
                                                                auto l = read(fd, buf, 1);
                                                                std::cout << t_id << ": Received " << l << std::endl;
                                                                if (l > 0)
                                                                    close(fd);
                                                                std::cout << t_id << ": Closed" << std::endl;
                                                            }
                                                            
                                                            
                                                            int main()
                                                            {
                                                                int s[2];
                                                                socketpair(AF_LOCAL, SOCK_STREAM, 0, s);
                                                            
                                                                char buf[2] = {};
                                                                write(s[1], buf, sizeof(buf));
                                                            
                                                                std::thread t1{[=]{ reader(1, s[0]); }};
                                                                std::thread t2{[=]{ reader(2, s[0]); }};
                                                                t1.join();
                                                                t2.join();
                                                            }
                                                            



                                                            Примеры вывода
                                                            2: Started
                                                            2: Received 1
                                                            2: Closed
                                                            1: Started
                                                            1: Received -1
                                                            1: Closed
                                                            

                                                            2: Started1: Started
                                                            1
                                                            : Received 1
                                                            1: Closed
                                                            2: Received 1
                                                            2: Closed
                                                            

                                                            • 0
                                                              Возможно я вас неправильно понял и вы специально сделали чтение по 2 байта, чтобы второй read заблокировался.

                                                              Тогда не совсем понятно, что вы бы ожидали получить? Чтобы второй read прервался в тот момент, когда первый поток закрывает сокет?
                                                              • 0
                                                                Да, разумеется, и еще чтобы другой конец сессии видел закрытый сокет. А как еще он должен себя вести?
                                                              • 0
                                                                Нет, это не ошибка. Второй сокет специально подвешивается на read(), вопрос почему он не видит закрытия сокета в другом потоке.
                                                        • 0
                                                          То, что вы говорите, это в высшей степени возмутительно! Ибо опреации на сокетах, как и другие системные вызовы, являются функциями класса async signal safe [1], то есть разрешенными к использованию в обработчиках сигналов. Рассказывать, почему функция, которая использует мьютекс не может являться async signal safe?

                                                          [1] man 7 signal
                                                          • 0
                                                            Там не юзерский мьютекс, а ядерный, специально спроектированный для работы с сокетом.

                                                            А вообще, да, расскажите, что происходит, если из обработчика сигнала вызвать read, а данных еще нет :)
                                                            • 0
                                                              Подождет пока к нему придут данные. Вместе с ним подождет и прерванный поток. Большого смысле в этом, наверное, нет, но если они таки придут, то ничего страшного не случится. Так, например, write в обработчиках используется и в хвост, и в гриву. А вы в своём каменте обощили до всех операций над сокетом, а теперь пытаетесь доказать, что смысла в таком использовании каких-то конкретных функций нет.

                                                              Ниже вашего камента, коллега ошибочно показал на список согласно стандарта thread safe functions, который на самом деле является списком non thread safe functions. Так вот ни read, ни close, ни какой-либо другой функции там нет.
                                                              • 0
                                                                Касательно ядренного мутекса. Допустим, что он там есть. Каким образом, он может спровоцировать описываемое поведение да ещё так, чтобы оно считалось корректным с т.з. стандарта?

                                                                Если вы говорите, что там race condition, то мьюеткс как бы должен как раз помогать избегать race condition.
                                                                Если мы говорим о зависании, то это уже dead lock, но для возможности dead lock необходимо наличие нескольких мутексов.
                                                                • 0
                                                                  Вызов из двух потоков некорректен.
                                                                  А мьютекс там нужен, чтобы ядро могло защититься от дураков и сохранить внутренние структуры в консистентном состоянии.
                                                                  Совсем не обязательно что после такой защиты вы получите хоть какой-то полезный или повторяемый результат.
                                                              • 0
                                                                man7.org/linux/man-pages/man7/pthreads.7.html
                                                                Thread-safe functions

                                                                Там чуть больше чем ничего.

                                                                • 0
                                                                  Мне кажется, вы не прочитали то, что предшествует непосредственно этому списку функций:
                                                                  POSIX.1-2001 and POSIX.1-2008 require that all functions specified in the standard shall be thread-safe, except for the following functions
                                                                  • 0
                                                                    Вы неверно понимаете что имеется в виду под thread-safe в POSIX.

                                                                    Например memcpy потокобезопасна с точки зрения POSIX.
                                                                    То есть ее ВООБЩЕ можно вызывать из нескольких потоков одновременно.

                                                                    Однако вызов из двух потоков с одним и тем же приемным буфером не является потокобезопасным.

                                                                    Точно по той же причине, одновременный вызов read для одного и того же дескриптора из разных потоков тоже не является безопасным, хотя read сама по себе потокобезопасна.
                                                                    • 0
                                                                      А это где-то описано?
                                                                      • 0
                                                                        Я не знаю где описано.
                                                                        Поэтому привел простой и понятный пример (memcpy) подтверждающий мои слова.
                                                                        • 0
                                                                          Если thread-safe, значит можно как хочешь, хоть с одним дескриптором, хоть с дестью.
                                                                          А пример с буфером плохой. Причем тут ядро? С его точки зрения все нормуль и его поведение не меняется.
                                                                          А в нашем случае меняется — BUG.

                                                                          • 0
                                                                            Действительно. Причем тут ядро? Где у меня про ядро в комменте про memcpy?
                                                                            • 0
                                                                              При всей силе аргументов, которые приводит уважаемый amosk то, что он пытается приподать под соусом thread safe, является на самом деле async signal safe. С этой точки зрения memcpy не является async signal safe функций, а все сокетные операции — являются. При этом и те, и другие являются thread safe.
                                                                            • 0
                                                                              Ваш пример подверждает только ваш пример, ввиду фундаментальности ограничений с данной опеацией. Вы же пытаетесь распространить это на весь стандарт POSIX. Это нелогично и незаконно.
                                                                              • 0
                                                                                Нет. Наоборот.
                                                                                Мой пример доказывает, что потокобезопасность по POSIX не гарантирует отсутствие race condition.
                                                                                А именно race condition и является причиной наблюдаемого поведения.
                                                                                • 0
                                                                                  В случае с memcpy нету race condition. У вас просто будет data inconsistency. А memcpy успешно отработает и не зависнет.
                                                                                  • 0
                                                                                    A race condition or race hazard is the behavior of an electronic or software system where the output is dependent on the sequence or timing of other uncontrollable events.


                                                                                    Т.е. буквально то что происходит при копировании в один буфер из нескольких потоков.

                                                                          • 0
                                                                            Согласитесь, что если бы это было так, то сокет был бы сделан без упомянутого вами мутекса, так как синхронизация доступа к сокета — это был бы гемор программиста.

                                                                            Кроме того, если вы беретесь говорить за весь POSIX, то не сочтите за труд сюда ссылку или цитату в качестве пруфа.
                                                                            • 0
                                                                              У вас есть возражения против того что оба эти условия выполняются?
                                                                              1) memcpy потокобезопасна по POSIX
                                                                              2) memcpy не потокобезопасна при передаче одного и того же буфера из разных потоков
                                                                              • 0
                                                                                То, что мы обсуждаем, это именно то, что гарантирует async signal safe. memcpy не является async signal safe функцией именно по пункту 2. A все операции с файловыми дескрипторами — являются.

                                                                                Описанный автором поста случай ничем не отличается от случая, когда мы имеем однопотоковое приложение, которое делает в основном потоке close, в этот момент случается какой-то сигнал и в нём оно делает read или write на данном дескрипторе. Этот же race condition (если он существует), приведет к такому же подвисанию, а значит это — bug.
                                                                              • 0
                                                                                Касательно мьютекса в сокете — почитайте исходники линукса.
                                                                              • 0
                                                                                Вызов read() из разных потоков совершенно безопасен и законен. Другое дело что невозможно предсказать какие данные будут прочитаны каким из потоков, но это не является ограничением на потокобезопасность. Например, я могу интерпретировать поток данных как набор независимых однобайтных команд, или вообще не интересоваться содержимым а использовать read() для синхронизации. Но сам вызов совершенно потокобезопасен.
                                                                              • 0
                                                                                Точно, тогда open, close, read, write — thread-safe.
                                                                                И их поведение не должно зависеть от количества threads их вызывающих, значит BUG.
                                                                                • 0
                                                                                  А вот в баги кернела я не верю. В 99.999% случаев ими прикрывают незнание системы. Давайте лучше разберемся, истина где-то рядом.
                                                                                  • 0
                                                                                    А вот это зря. Чем ваш случай от этого отличается?
                                                                                    lkml.org/lkml/2012/12/18/368
                                                                                    Тоже потоки, тоже дескрипторы.
                                                                                    • 0
                                                                                      Да нет, я просто хотел сказать что баги в кернеле встречаются исключительно редко, как правило ими прикрывают свое незнание.
                                                                                      В любом случае, конкретно эта фишка прекрасно известна всем кто переходил на Linux с любого другого Unix'a в середине 90х, так что для бага она слишком стара. Если бы найти тьюториалы по сетевому программированию за тот период, можно было бы найти вполне авторитетные источники прямо предлагающие паттерны которые на Linux не работают.
                                                                                    • +1
                                                                                      Сделате приложению kill -3 в таком состоянии. Давайте посмотрим в корку.
                                                                                      • 0
                                                                                        Хорошая идея, только ничего интересного я не увидел:
                                                                                        (gdb) info thr
                                                                                          Id   Target Id         Frame 
                                                                                          2    Thread 0x7fe8c11e7700 (LWP 16403) 0x00007fe8c25dd3bd in read ()
                                                                                            at ../sysdeps/unix/syscall-template.S:81
                                                                                        * 1    Thread 0x7fe8c29e5780 (LWP 16401) 0x00007fe8c25d766b in pthread_join (
                                                                                            threadid=140637649139456, thread_return=0x0) at pthread_join.c:92
                                                                                        (gdb) 
                                                                                        

                                                                                        Хотите, дамп выложу куда-нибудь
                                                                                        • 0
                                                                                          По крайней мере, мы точно знаем, что повисло в системном вызове, а не в обертке libc.
                                                                      • 0
                                                                        del
                                                                        • 0
                                                                          Товарищи amosk encyclopedist izyk degs
                                                                          Огромная просьба от меня и других, кто следит за комментариями к этой статье, — опубликуйте результаты просветления про потоки и дескрипторы в виде отдельной статьи (если просветление всё-таки наступит и все с ним согласятся).
                                                                          • 0
                                                                            Тут нет инфы достойной отдельной статьи.

                                                                            Как выше написал encyclopedist:
                                                                            1) If CLONE_FILES is set, the calling process and the child process share the same file descriptor table
                                                                            2) в glibc в функции create_thread используестся такой набор флагов ...CLONE_FILES…

                                                                            Т.е. ничего не клонируется.
                                                                            Вопрос закрыт.
                                                                            • 0
                                                                              Обязательно, если просветление все-таки наступит и мы не поубиваем друг друга в пылу дискуссии.

                                                                            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.