Заметки о Unix: сильные и слабые стороны errno в традиционных Unix-окружениях +31


AliExpress RU&CIS

Недавно я мимоходом отметил, что errno был, в целом, хорошим интерфейсом в Unix-системах до появления в них многопоточности. Кого-то подобное высказывание может удивить, поэтому сегодня предлагаю поговорить о сильных и слабых сторонах errno в традиционных Unix-окружениях, таких, как V7 Unix.



Сильной стороной errno является тот факт, что этот интерфейс представляет собой простейший механизм, способный возвращать несколько значений из системных вызовов C, в которых нет непосредственной поддержки возврата нескольких значений (особенно — в ранних вариантах C). Использование глобальной переменной для «возврата» второго значения — это практически идеал того, что можно сделать в обычном C, если только не планировать передачу из C-библиотеки указателя на каждый системный вызов и функцию, которые собираются возвращать значение errno (при таком подходе придётся, например, интенсивно пользоваться stdio). Постоянная передача подобного указателя приводит не только к ухудшению внешнего вида кода. Такой подход увеличивает объём кода, и, из-за использования дополнительного параметра, приводит к повышению нагрузки на стек (или на регистры).

(Современный C способен на такие фокусы, как возврат двухэлементной структуры в паре регистров, но этого нельзя сказать о более старых и более простых версиях C, используемых, как минимум, в Research Linux V7.)

Некоторые системные вызовы C-библиотек Unix в V7 могли возвращать сведения об ошибке в виде специального значения, и, вероятно, нельзя говорить о том, что все они поддерживали подобную возможность (в V7 действовали ограничения на количество файлов, да и адресное пространство на PDP-11 тоже было достаточно ограниченным). Даже если бы это поддерживали все вызовы, это привело бы к необходимости писать больше кода в случаях, когда нужно было проверять возвращаемые значения команд вроде open() или sbrk(). В C-коде пришлось бы проверять то, в каком диапазоне значений находится возвращаемое значение, или другие характеристики этого значения.

(Реальные системные вызовы в V7 Unix и до неё использовали метод оповещения об ошибках, спроектированный для ассемблера, когда ядро было настроено на возврат в регистр r0 либо результата системного вызова, либо номера ошибки, и на выполнение установок, зависящих от того, что именно было возвращено. Почитать об этом можно в справке по dup для V4, которая написана в те времена, когда к Unix ещё готовили серьёзную ассемблерную документацию. C-библиотека V7 сделана так, что при возникновении ошибки делается запись в errno и возвращается -1. Почитайте, например, libc/sys/dup.s вместе с libc/crt/cerror.s.)

Слабая сторона errno заключается в том, что это — самостоятельное глобальное значение. То есть — оно может быть случайно перезаписано в том случае, если между моментом, когда в него, интересующим нас системным вызовом, были записаны сведения об ошибке, и моментом, когда мы решили воспользоваться errno, что-то ещё записало в него сведения о собственной ошибке. Подобное легко может произойти тогда, когда, после сбоя, выполняется прямое или непрямое обращение из обычного кода к какому-нибудь системному вызову, который тоже даёт сбой. Классической ошибкой такого рода была попытка сделать проверку того, является ли стандартный вывод (или стандартный вывод ошибки) терминалом. Делается это путём выполнения на нём TTY-вызова ioctl(). Когда вызов ioctl() завершится с ошибкой, исходное значение errno будет перезаписано значением ENOTTY, и причина ошибки, из-за которой завершился вызов open() или какой-то другой вызов, будет описана таинственным сообщением not a typewriter (cf).

Даже если вы избежали этой ловушки — у вас могут возникнуть проблемы с сигналами, так как сигналы могут прерывать выполнение программ в любых местах, в том числе — сразу после возврата из системных вызовов и до того, как было проанализировано значение, хранящееся в errno. В наши дни обычно не ожидается, что в обработчиках сигналов будут выполнять какие-то действия, но в давние времена в них могли делать очень много всего. Особенно, например, в обработчике сигнала SIGCHLD, где, чтобы узнать о статусе выхода дочернего процесса, вызывали wait() до тех пор, пока он не завершался с ошибкой и с записью чего-то в errno, что, если это было сделано в неудачное время, привело бы к перезаписи исходного значения errno. Обработчик сигнала может быть рассчитан на работу в таких условиях в том случае, если программист помнит об этой проблеме, но о ней вполне можно и забыть. Программисты часто упускают из виду особенности работы программ, связанные со временем, способные вызывать «состояние гонок», особенно тогда, когда речь идёт о маленьких промежутках времени, и в случаях, когда проблемы, связанные со временем, возникают нечасто.

(В V7 не было сигнала SIGCHLD, но он был в BSD. Это так из-за того, что в BSD появилась система управления заданиями, что и привело к необходимости наличия подобного сигнала. Но это — уже совсем другая история.)

В целом же я полагаю, что errno был хорошим интерфейсом, учитывая ограничения традиционной Unix, когда не было многопоточности или нормальных способов возврата нескольких значений из вызовов C-функций. Хотя у него есть и минусы, и слабые стороны, их обычно можно было обойти, и обычно они не слишком часто давали о себе знать. API errno стал выглядеть весьма нескладно только тогда, когда в Unix появилась многопоточность, и в одном адресном пространстве могло присутствовать несколько сущностей, одновременно выполняющих системные вызовы. Как и большая часть того, что имеется в Unix (в особенности — в эру Research Unix V7), это — не идеальное, хотя и вполне приемлемое решение.

Сталкивались ли вы с проблемами errno?




Комментарии (14):

  1. justhabrauser
    /#23017186

    Жуткий перевод.
    Что-то гуглетранслейт не в ударе сегодня.

  2. agmt
    /#23017312

    Это из какого года статья?
    Я точно помню, что впервые, как я посмотрел на errno, это уже был макрос на функцию. Так что никаких проблем с многопоточностью.
    Если я правильно понял man7.org/linux/man-pages/man7/signal-safety.7.html, там явно указано, что надо сохранить errno и восстановить, если хотите вызывать функции из разрешённого списка, устанавливающие errno.

    • khim
      /#23019016

      Какая разница какого года статья, если в ней обсуждается “единый UNIX”, каким он был до седьмой версии (то есть до 1980го года).
      А как-то сомневаюсь, что вам повезло с ним в те годы познакомиться. Разве что вы ДЕМОС застали (я про него только в книжках читал).

      Я точно помню, что впервые, как я посмотрел на errno, это уже был макрос на функцию. Так что никаких проблем с многопоточностью.
      Ну да, только куча проблем с эффективностью.

      Вообще на 32-битных системах это плохой интерфейс. Применямый в Linux (всё что от -4095 до -1 — ошибка, вне этого диапазона — возвращаемое значение) гораздо удобнее.

      Ваш man в отношение к обсуждаемой статье — это что-то из далёкого “светлого” будущего.

  3. git-merge
    /#23017518

    С многопоточностью проблемы errno вполне решаемы (главное чтоб кто-то захотел их решать). Просто тред-зависимую переменную сделать и всё.

    • staticmain
      /#23017792

      Она уже лет 15-20 такая

      • git-merge
        /#23018240

        видимо не везде, раз статьи вот такие появляются

        • staticmain
          /#23018274

          Не слышал про платформы поддерживающие многопоточность и не поддерживающие __thread/pthread_setspecific для errno. МК-шные платформы в многопоточность не очень.

          • git-merge
            /#23019330

            я тоже не слышал, может автор нам пример приведёт?

        • khim
          /#23019022

          А вас не смутило что в статье обсуждается Unix 30-летней давности, нет?

  4. unsignedchar
    /#23018258

    Современный C способен на такие фокусы, как возврат двухэлементной структуры в паре регистров

    Никогда не видел такого :/ Это время уже прошло или ещё не наступило?

    • staticmain
      /#23018278

      Полагаю, что автор имел в виду что-то вроде

      struct s {
          int a;
          int b;
      };
      
      struct s func(int a, int b) {
          struct s is;
          is.a = a;
          is.b = b;
          return is;
      }

      func:
      movd xmm0, edi
      movd xmm1, esi
      punpckldq xmm0, xmm1
      movq rax, xmm0
      ret

      • khim
        /#23019062

        Аааа… мои глаза! Это что за компилятор у вас такую дичь нагенерил? Нормальный же код большая четвёрка порождает.

        Но тут у вас один регистр. Чтобы два было нужно, скажем, указатель и код ошибки вернуть

        Но главная проблема с обсуждаемыми временами — это даже не то, что структуру нельзя вернуть эффективно, а то, что её нельзя вернуть вообще. K&R C этого не поддерживает. Хотя C89 поддерживает и многие компиляторы так умели уже в 80е… но не в обсуждающиеся в статье 70е!

        • staticmain
          /#23019090

          Аааа… мои глаза! Это что за компилятор у вас такую дичь нагенерил?


          Gcc 10: gcc.godbolt.org/z/GzPTej6Ka

          • khim
            /#23019120

            -O3

            В GCC с ним осторожнее надо. Вот именно потому что оно побуждает всё агрессивно векторизовать, засовывать в SIMD'ы, что далеко не всегда приводит к выигрышу в скорости и почти всегда — к проигрышу в размере кода.

            Но это как раз потому что в один регистр упаковывать в обычных регистрах не так удобно, когда нужны именно два регистра таких чудес нету.