2025, Oct 31 11:47
Чтение stdin с тайм‑аутом в Python на Unix: select и raw‑режим
Почему os.read не даёт тайм-аут и как читать stdin с тайм‑аутом в Python на Unix: select, raw‑режим, обработка backspace и Ctrl‑C, безопасное восстановление настроек.
Чтение из stdin с тайм‑аутом кажется простой задачей, пока не сталкиваешься с блокирующим вводом‑выводом и режимами терминала. Частая ловушка — принять аргумент вызова read за тайм‑аут в секундах, тогда как на деле это максимум байтов, которые нужно получить. В итоге скрипт будто «зависает» и никогда не истекает по времени. Разберёмся, что происходит, и как сделать всё правильно в Unix‑подобных системах.
Как воспроизвести проблему
Фрагмент ниже пытается прочитать ввод пользователя якобы с тайм‑аутом в 5 секунд. На деле он блокируется до появления данных и никогда не возвращает None, если пользователь молчит.
import os
import tty
import sys
import time
in_fd = sys.stdin.fileno()
tty.setraw(in_fd)
print("Enter some text:", end=' ', flush=True)
start_ts = time.time()
buf = os.read(in_fd, 5)
print("Time taken:", time.time() - start_ts, "d:", buf)
Почему так происходит
Здесь важно понять две вещи. Во‑первых, stdin по умолчанию — блокирующий файловый дескриптор. Вызов os.read на нём ждёт, пока появятся данные. Во‑вторых, сигнатура os.read такова, что второй аргумент — это не тайм‑аут, а количество байтов для чтения. Значение 5 означает «прочитать до пяти байтов», а не «подождать пять секунд». Поэтому код остаётся на приглашении сколь угодно долго — пока не придёт ввод. В raw‑режиме терминал доставляет нажатия сразу, без построчного буферизования, поэтому символы приходят по одному; но это не делает чтение неблокирующим и не добавляет тайм‑аут.
Тем не менее stdin — «ожидаемый» объект: можно спросить у ОС, готовы ли данные к чтению, прежде чем читать. Для этого используют select с тайм‑аутом. Если за отведённое время select сигнализирует готовность — продолжаем; иначе считаем, что наступил тайм‑аут.
Решение: используйте select с тайм‑аутом и аккуратно управляйте raw‑режимом
Подход ниже переводит терминал в raw‑режим, показывает приглашение и ждёт до указанного тайм‑аута с помощью select. Если приходит ввод, обрабатываются типичные управляющие символы интерактивного ввода — backspace, возврат каретки и escape‑последовательности. Если раньше истекает тайм‑аут, функция возвращает None. И принципиально важно: при выходе настройки терминала восстанавливаются.
from select import select
from sys import stdin
from tty import setraw
from termios import tcsetattr, TCSAFLUSH
from functools import partial
BK = "\b"
DELETE = "\x7f"
SIGINT = "\x03"
RET = "\r"
ESCAPE = "\x1b"
CSI = ESCAPE + "["
ERASE_LINE = CSI + "K"
REFRESH = RET + ERASE_LINE
ECHO = partial(print, end="", flush=True)
CSI_DIGITS = set("0123456789;?")
def is_ready(fd: int, timeout: float) -> bool:
    r, _, _ = select([fd], [], [], timeout)
    return bool(r)
def swallow_escape() -> None:
    if stdin.read(1) == "[":
        while stdin.read(1) in CSI_DIGITS:
            pass
def read_with_timeout(prompt: str = "", timeout: float = 0.0) -> str | None:
    if timeout <= 0.0:
        return input(prompt)
    ECHO(prompt)
    fd = stdin.fileno()
    prev_attrs = setraw(fd)
    buf = ""
    try:
        while True:
            if not is_ready(fd, timeout):
                return None
            ch = stdin.read(1)
            if ch in {RET, SIGINT}:
                break
            if ch == ESCAPE:
                swallow_escape()
                continue
            if ch in {BK, DELETE}:
                if buf:
                    buf = buf[:-1]
                    ECHO(f"{REFRESH}{prompt}{buf}")
            else:
                buf += ch
                ECHO(ch)
    finally:
        tcsetattr(fd, TCSAFLUSH, prev_attrs)
        print()
    return buf
if __name__ == "__main__":
    print(read_with_timeout("Enter something: ", 5))
Это может работать только на Unix‑подобных платформах.
Что здесь происходит на самом деле
stdin можно передавать в select, чтобы проверять готовность. Этот вызов принимает тайм‑аут и возвращается, когда дескриптор становится готов к чтению, либо когда время истекает. Совмещая его с raw‑режимом, вы получаете мгновенную доставку нажатий без канонического построчного редактирования и сохраняете точный контроль над тем, что считать «окончанием ввода». Приведённый код завершает ввод по возврату каретки, корректно реагирует на Ctrl‑C, игнорирует escape‑последовательности и перерисовывает строку при обработке backspace/delete, чтобы сохранить аккуратное приглашение.
Если вы задаётесь вопросом, почему изначальная попытка будто принимала ввод по одному символу, — это следствие raw‑режима: символы сразу становятся доступны процессу. Но это не отменяет того, что блокирующее чтение ждёт, пока появится хотя бы один байт, а переданная «5» лишь ограничивает объём возвращаемых данных, а не время ожидания.
Есть и другой рабочий приём: запустить в отдельном потоке «читателя», который выполняет блокирующее чтение с клавиатуры и складывает результат в очередь, а в основном потоке вызывать get у этой очереди с тайм‑аутом. Так вы тоже избегаете блокировок основного потока и получаете контроль по времени.
Почему это важно
Интерактивные CLI‑утилиты, TUI‑приложения и REPL‑подобные инструменты постоянно сочетают пользовательский ввод с таймерами, индикаторами прогресса и другими асинхронными событиями. Непонимание принципов блокирующего I/O приводит к «подвисанию» программ и хрупкому UX. Применение select с тайм‑аутом к stdin обеспечивает предсказуемое поведение и даёт контроль, необходимый для отзывчивых терминальных взаимодействий.
Выводы
Когда нужен тайм‑аут для терминального ввода, не возлагайте на os.read несвойственных ожиданий. Считайте stdin источником, готовность которого можно ждать: сначала вызовите select с тайм‑аутом, и только затем читайте. Не забывайте включать raw‑режим, когда требуется моментальная реакция на нажатия, и обязательно возвращать настройки обратно. Если вы не хотите напрямую менять режимы терминала, перенесите блокирующее чтение в рабочий поток с очередью — это тоже жизнеспособный вариант.
Статья основана на вопросе на StackOverflow от DeepThought42 и ответе от Ramrab.