2025, Sep 28 01:15

Как корректно читать строки из канала в Python: EOF и readline

Разбираем, как читать строки из канала в Python и правильно завершать цикл: поведение readline при EOF, почему не нужен select, надёжный шаблон чтения.

Чтение строк из канала и определение момента, когда записывающая сторона завершила работу, кажется простой задачей, но её легко переусложнить. Распространённая ловушка — совмещать низкоуровневые проверки готовности с высокоуровневым файловым вводом-выводом и в итоге упускать чистый сигнал окончания потока. Хорошая новость: файловый интерфейс уже даёт ответ — и всё проще, чем выглядит.

Постановка задачи

Нужно построчно читать текст из канала, пока другая часть программы записывает туда небольшие фрагменты. Сложность в том, чтобы корректно определить момент закрытия записывающего конца и чисто выйти из цикла чтения. В примере ниже цикл чтения никогда не завершается, и join блокируется до ручного прерывания.

from datetime import datetime
from itertools import batched
import os
from select import select
from threading import Thread
from time import sleep

# Каждая надстрочная цифра кодируется в 3 байта.
sample_txt = '⁰¹²\n³\n⁴\n⁵⁶\n⁷⁸⁹⁰¹²\n³'
sample_buf = bytes(sample_txt, 'utf8')

r_fd, w_fd = os.pipe()

bin_out = open(w_fd, 'wb', buffering=0)
txt_in = open(r_fd, 'r')

collected = []

def pump_reader():
    t_prev = datetime.now()
    while True:
        sleep(1)
        t_now = datetime.now()
        print('A', (t_now - t_prev).total_seconds())
        t_prev = t_now

        r_ready, w_ready, e_ready = select([txt_in], [txt_in], [txt_in], 0)
        if txt_in.closed:
            break
        if e_ready:
            break
        if not r_ready:
            continue

        piece = txt_in.readline()
        print('B', (t_now - t_prev).total_seconds())
        t_prev = t_now

        if piece:
            print('got chunk', repr(piece))
            collected.append(piece)

worker = Thread(target=pump_reader)
worker.start()

for segment in batched(sample_buf, 4):
    payload = bytes(segment)
    sleep(1.6)
    bin_out.write(payload)

bin_out.close()

worker.join()

print(repr(collected))

Наблюдаемое поведение: все ожидаемые строки приходят, включая последнюю без завершающего переноса строки, но цикл чтения не выходит, и поток остаётся жив.

Что происходит на самом деле

Когда записывающий конец канала закрывается и данных больше нет, построчное чтение через readline возвращает пустую строку. Эта пустая строка — сигнал конца файла (EOF) для файловых объектов в текстовом режиме. Иными словами, как только последняя неполная строка доставлена, следующий вызов readline возвращает "". Если цикл это не проверяет и просто продолжает работу, он не завершится.

Чёткий сигнал уже есть на уровне файлового интерфейса. В этом сценарии нет нужды комбинировать select с проверками closed или списками исключений. Главное — трактовать пустую строку от readline как конец потока.

Решение

Положитесь на логическое значение, которое возвращает readline. Как только он вернёт пустую строку, цикл естественным образом завершится. В итоге получается компактный и корректный цикл чтения.

def pump_reader():
    while line := txt_in.readline():
        print('got chunk', repr(line))
        collected.append(line)

Такой вариант делает семантику EOF явной и надёжной. Равнозначный подход — итерироваться по самому файловому объекту: он будет выдавать строки до конца потока.

Почему это важно

При работе с каналами подпроцессов или любыми конвейерами «производитель–потребитель» корректный путь завершения так же важен, как и производительность в штатном режиме. Опора на поведение EOF в файловом API упрощает код и предотвращает скрытые зависания, когда потоки ждут бесконечно, а join блокируется до принудительного прерывания. Если вы уже читаете текст через readline, позвольте ему сообщить об окончании потока.

Выводы

Не спорьте с файловым интерфейсом. Если читаете через readline, воспринимайте пустую строку как конец — и цикл завершится без вспомогательных проверок или таймаутов. Это снижает сложность, убирает крайние случаи с неполными строками и делает жизненный цикл потоков предсказуемым.

Статья основана на вопросе на StackOverflow от Steve Jorgensen и ответе 0ro2.