使用选择器实现非阻塞的标准输入

5
使用Python中的selectors结合sys.stdin进行非阻塞控制台输入实验时,我有一个疑问:假设我想在用户按下Enter键后退出循环,可能先输入一些其他字符。如果我执行以下阻塞读取操作,则进程始终在遇到第一个换行符\n后完成,而不管之前的任何字符,这是预期的:
import sys

character = ''
while character != '\n':
    character = sys.stdin.read(1)

现在考虑以下非阻塞读取的最小化示例:
import sys
import selectors

selector = selectors.DefaultSelector()
selector.register(fileobj=sys.stdin, events=selectors.EVENT_READ)

character = ''
while character != '\n':
    for key, __ in selector.select(timeout=0):
        character = key.fileobj.read(1)

如果我在第一次输入时按下 Enter,那么会生成一个换行符,并且进程会正常结束。
然而,如果我先输入其他字符,然后再按下 Enter,进程就不会结束:我需要再次按下 Enter 才能结束。
显然,这个实现只在第一个输入为换行符时有效。
可能有很好的原因,但我目前还没有看到它,也找不到任何相关的问题。
这是与我的非阻塞实现有关,还是与 stdin 缓冲区有关,或者与控制台或终端实现有关呢?
(我正在 ubuntu 上的 python 3.8 shell 中运行此程序。)

1
请参见 https://dev59.com/QGct5IYBdhLWcg3wPbMm#43929760 - 我认为这里与终端模式有关... - AKX
@AKX:谢谢!将终端设置为“cbreak”模式似乎确实有效。 - djvg
@AKX:我仍然想知道为什么这只发生在“选择器”实现中。 - djvg
使用底层二进制 sys.stdin.buffer 会有什么区别吗? - AKX
请注意,第二个 <enter>(以及任何先前的输入)在 Python 进程退出后由 shell 处理。 - djvg
一些相关问题:https://dev59.com/ALPma4cB1Zd3GeqPliKe,https://dev59.com/TGEh5IYBdhLWcg3w_nyv,https://dev59.com/03E95IYBdhLWcg3wN7Kv,https://stackoverflow.com/q/34067884,https://stackoverflow.com/q/34067884 - djvg
1个回答

0

sys.stdinio.TextIOWrapper 的一个实例,它又包装了 io.BufferedReader 的一个实例。

在你的代码末尾添加 print(repr(character)) 可以让你看到正在发生的事情:

$ python foo.py 
# enter asd\n
'a'
# enter \n
's'
'd'
'\n'

当你第一次输入 "asd\n" 时,Python 将其全部读入缓冲区,但仅返回第一个字符。由于底层的 stdin 现在为空,下一次调用 selector.select() 再次阻塞。当你输入另一个换行符时,select 解除阻塞,代码继续从缓冲区读取字符,直到达到第一个 "\n"。

你可以通过使用 os.read(key.fileobj.fileno(), 1) 来绕过缓冲。这会给出预期的行为(请注意,它返回字节而不是字符串):

$ python foo.py 
# enter asd\n
b'a'
b's'
b'd'
b'\n'

编辑: 有关cbreak模式的一些上下文

来自man 3 cbreak:

通常,tty驱动程序会缓冲键入的字符,直到键入换行符或回车符。 cbreak例程禁用行缓冲和擦除/杀死字符处理(中断和流控制字符不受影响),使用户键入的字符立即可用于程序。 nocbreak例程将终端返回到正常(熟食)模式。

在cbreak模式下,字符逐个发送,因此Python无法缓冲输入:

$ python foo.py 
# enter a
'a'
# enter s
's'
# enter d
'd'
# enter \n
'\n'

请注意,selector.select() 不会阻塞,因为我们明确设置了 timeout=0。我们只是在轮询“可读事件”。另外,os.read(key.fileobj.fileno(), 1) 可以简化为 os.read(key.fd, 1)。不幸的是,这并没有回答实际问题,即为什么原始示例不能按预期工作。 - djvg
你关于 timeout=0 是正确的,我漏掉了这个。不过解释仍然成立:在初始读取之后,标准输入为空。无论 select 阻塞还是返回一个空列表,基本上都有相同的效果。 - tobib
抱歉,但是在我看来,这个答案描述了(扩展了@AKX的评论),但并没有解释。例如:你实际上是什么意思说“stdin为空”?你是指Python文件对象还是底层操作系统输入流?如果“stdin为空”,正如你所说,为什么第二个<enter>后的read(1)调用会返回剩余的字符(它们从哪里来)?为什么select在第一个字符读取后不立即检测到剩余的字符?为什么select在第二个<enter>之后突然检测到所有剩余的字符,而不仅仅是一个? - djvg

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接