在Ruby中持续读取外部进程的标准输出

91
我希望通过一个 Ruby 脚本从命令行启动 Blender,然后逐行处理 Blender 输出的内容以更新 GUI 中的进度条。重要的不是 Blender 是需要读取 stdout 的外部进程。
我似乎无法捕获 Blender 在进程仍在运行时通常打印到 shell 的进度消息,并且我已经尝试了几种方法。 我总是能够在 Blender 退出后才访问 Blender 的 stdout,而不是在其运行时。
以下是失败尝试的示例。它确实获取并打印了 Blender 输出的前 25 行,但只是在 Blender 进程退出后:
blender = nil
t = Thread.new do
  blender = open "| blender -b mball.blend -o //renders/ -F JPEG -x 1 -f 1"
end
puts "Blender is doing its job now..."
25.times { puts blender.gets}

编辑:

为了让问题更加清晰,调用blender的命令会在shell中返回一系列输出流,表示进度(已完成1-16部分等)。似乎任何对"gets"的调用都会被阻塞,直到blender退出。问题在于如何在blender仍在运行时访问此输出,因为blender将其输出打印到shell中。

6个回答

182
我已经在解决我的问题方面取得了一些成功。以下是详细信息,附有一些解释,以防有类似问题的人找到此页面。但如果您不关心细节,这里是简短的答案:
请按以下方式使用PTY.spawn(当然要用自己的命令):
require 'pty'
cmd = "blender -b mball.blend -o //renders/ -F JPEG -x 1 -f 1" 
begin
  PTY.spawn( cmd ) do |stdout, stdin, pid|
    begin
      # Do stuff with the output here. Just printing to show it works
      stdout.each { |line| print line }
    rescue Errno::EIO
      puts "Errno:EIO error, but this probably just means " +
            "that the process has finished giving output"
    end
  end
rescue PTY::ChildExited
  puts "The child process exited!"
end

这里是长答案,包含过多细节:

真正的问题似乎是,如果一个进程没有显式地刷新其标准输出(stdout),那么任何写入stdout的内容都会被缓冲而不是实际发送,直到进程完成,以最小化IO(这是许多C库的实现细节,通过较少的频繁IO来最大化吞吐量)。如果您可以轻松修改进程以定期刷新stdout,则可以解决此问题。在我的情况下,它是blender,因此对于像我这样的完全新手来说有点令人生畏,无法修改源代码。

但是当您从shell运行这些进程时,它们会实时将stdout显示到shell中,并且stdout似乎不会被缓冲。只有在从另一个进程调用时才会缓冲,但是如果正在处理shell,则可以实时看到stdout,未经缓冲。

即使使用ruby进程作为必须实时收集其输出的子进程,也可以观察到此行为。只需创建一个名为random.rb的脚本,并添加以下行:

5.times { |i| sleep( 3*rand ); puts "#{i}" }

然后编写一个Ruby脚本来调用它并返回输出:

IO.popen( "ruby random.rb") do |random|
  random.each { |line| puts line }
end

你会发现,与你预期的实时获得结果不同,所有结果都是事后一次性输出的。即使你自己运行random.rb,标准输出仍然被缓冲了。可以通过在random.rb中添加STDOUT.flush语句来解决这个问题。但如果你不能改变源代码,就要绕过这个问题。你无法从进程外部刷新它。
如果子进程能够实时打印到Shell,那么必须有一种方法可以在Ruby中实时捕获它。确实有。你需要使用PTY模块,我相信它已经包含在Ruby核心中了(至少在1.8.6版本中是这样)。可悲的是,它没有文档。但是我很幸运地找到了一些使用示例。
首先,为了解释PTY是什么,它代表伪终端。基本上,它允许Ruby脚本将自己呈现给子进程,就好像它是一个真正的用户,刚刚在Shell中输入了命令一样。因此,只有当用户通过Shell启动进程时才会发生任何更改行为(例如,在这种情况下不缓冲标准输出),这种行为将发生。隐藏另一个进程启动了这个进程的事实,可以让你实时收集标准输出,因为它没有被缓冲。
要使这个代码与random.rb脚本作为子进程一起工作,请尝试以下代码:
require 'pty'
begin
  PTY.spawn( "ruby random.rb" ) do |stdout, stdin, pid|
    begin
      stdout.each { |line| print line }
    rescue Errno::EIO
    end
  end
rescue PTY::ChildExited
  puts "The child process exited!"
end

7
很好,但我认为应该交换stdin和stdout块参数。请参见:http://www.ruby-doc.org/stdlib-1.9.3/libdoc/pty/rdoc/PTY.html#method-c-spawn - Mike Conigliaro
1
如何关闭pty?杀死pid? - Boris B.
1
太棒了!你帮我改进了我的Heroku rake deploy脚本。它可以实时显示“git push”的日志,并在发现“fatal:”时中止任务。 https://gist.github.com/sseletskyy/9248357 - Sergiy Seletskyy
1
我原本尝试使用这种方法,但是在Windows中没有'pty'。事实证明,只需要STDOUT.sync = true(如下mveerman的答案)即可解决问题。这里有另一个帖子提供一些示例代码 - Pakman

13

使用 IO.popen。这是一个好的例子:链接

你的代码将变成类似这样:

blender = nil
t = Thread.new do
  IO.popen("blender -b mball.blend -o //renders/ -F JPEG -x 1 -f 1") do |blender|
    blender.each do |line|
      puts line
    end
  end
end

我已经尝试过这个了,问题还是一样。我能够访问输出结果。我相信IO.popen通过运行第一个参数作为命令开始,然后等待其结束。在我的情况下,输出由Blender提供,而Blender仍在处理中。之后执行块,这并没有对我有所帮助。 - ehsanul
这是我尝试过的代码。它会在 Blender 完成后返回输出结果:IO.popen( "blender -b mball.blend //renders/ -F JPEG -x 1 -f 1", "w+") do |blender|
blender.each { |line| puts line; output += line;}
end
- ehsanul
4
我不确定你的情况是发生了什么。我使用了上面的代码对“yes”进行了测试,这是一个永远不会结束的命令行应用程序,它可以正常工作。代码如下:IO.popen('yes') { |p| p.each { |f| puts f } }。我怀疑这与blender有关,而不是ruby。可能是因为blender没有始终清空其标准输出。 - Sinan Taifour
好的,我刚刚尝试了一下使用外部 Ruby 进程进行测试,你是对的。看来这是一个 Blender 的问题。无论如何,感谢你的回答。 - ehsanul
原來還是有辦法通過Ruby獲取輸出,盡管Blender不刷新其標准輸出。如果您感興趣,詳細信息稍後在單獨的答案中。 - ehsanul

8

STDOUT.flush or STDOUT.sync = true


是的,这个回答很差。你的回答更好。 - mveerman
不是骗人的!对我有用。 - Clay Bridges
更精确地说:STDOUT.sync = true; system('<任何命令>') - caram

4

Blender可能直到程序结束才打印换行符。相反,它会打印回车字符(\r)。最简单的解决方案可能是查找魔术选项,使用进度指示器打印换行符。

问题在于IO#gets(以及其他各种IO方法)使用换行符作为分隔符。它们将读取流,直到遇到"\n"字符(blender没有发送该字符)。

尝试设置输入分隔符$/ = "\r"或使用blender.gets("\r")代替。

顺便说一下,对于这类问题,您应该始终检查puts someobj.inspectp someobj(两者都执行相同的操作),以查看字符串中的任何隐藏字符。


1
我刚刚检查了输出结果,似乎blender使用了换行符(\n),所以那不是问题。无论如何,感谢你的提示,下次我调试类似的问题时会记住这个。 - ehsanul

1

我不知道当ehsanul回答这个问题时,是否已经有Open3::pipeline_rw()可用,但它确实使事情变得更简单。

我不理解ehsanul在Blender中的工作,因此我用tarxz做了另一个示例。 tar将输入文件添加到stdout流中,然后xz获取该stdout并再次将其压缩为另一个stdout。我们的任务是获取最后一个stdout并将其写入我们的最终文件:

require 'open3'

if __FILE__ == $0
    cmd_tar = ['tar', '-cf', '-', '-T', '-']
    cmd_xz = ['xz', '-z', '-9e']
    list_of_files = [...]

    Open3.pipeline_rw(cmd_tar, cmd_xz) do |first_stdin, last_stdout, wait_threads|
        list_of_files.each { |f| first_stdin.puts f }
        first_stdin.close

        # Now start writing to target file
        open(target_file, 'wb') do |target_file_io|
            while (data = last_stdout.read(1024)) do
                target_file_io.write data
            end
        end # open
    end # pipeline_rw
end

1

虽然这个问题比较老,但我遇到了类似的问题。

没有真正改变我的 Ruby 代码,但有一件事情帮了我很多,就是用 stdbuf 包装我的管道,像这样:

cmd = "stdbuf -oL -eL -i0  openssl s_client -connect #{xAPI_ADDRESS}:#{xAPI_PORT}"

@xSess = IO.popen(cmd.split " ", mode = "w+")  


在我的例子中,我想要与实际命令进行交互,就像它是一个shell一样,这个命令是openssl-oL -eL 告诉它仅缓冲STDOUT和STDERR直到换行符。将 L 替换为 0 可以完全取消缓冲。
然而,这并不总是有效的:有时目标进程会强制执行自己的流缓冲类型,就像另一个答案指出的那样。

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