Threads and Processes

Ruby
提供了两种管理程序的基本方式,所以你可以同时运行程序的不同部分。你可以通过多线程将同一程序中多个合作任务分离,你也可以通过多进程分离不同程序间的任务。让我们依次来看一下。

多线程

通常可以使用 Ruby 的线程一次性做两件事。这些都是进程内,并且在 Ruby 解释器中实现的。这种方式使 Ruby 线程变得便携,它不需要依赖操作系统,而且使用原生线程也不会获得什么好处。你可能会体验到线程饥饿(由于低优先权的线程没有机会运行)。如果你管理的线程发生了死锁,整个进程可能会逐渐停止。如果某些线程调用操作系统需要花费较长时间,所有线程都将挂起,直到解释器获得控制权。不过,不要让这些潜在的问题阻止你,Ruby 线程是一种轻量级且让代码有效获得并发功能的方式。

创建 Ruby 线程

创建新线程的方式十分直接。下面是一段简单的代码,用于对网页的平行下载。对每个请求而言,代码都为其创建了分隔的线程对 HTTP 交互进行处理。

require 'net/http'

pages = %w( www.rubycentral.com
            www.awl.com
            www.pragmaticprogrammer.com
           )

threads = []

for page in pages
  threads << Thread.new(page) { |myPage|

    h = Net::HTTP.new(myPage, 80)
    puts "Fetching: #{myPage}"
    resp, data = h.get('/', nil )
    puts "Got #{myPage}:  #{resp.message}"
  }
end

threads.each { |aThread|  aThread.join }

结果是:

Fetching: www.rubycentral.com
Fetching: www.awl.com
Fetching: www.pragmaticprogrammer.com
Got www.rubycentral.com:  OK
Got www.pragmaticprogrammer.com:  OK
Got www.awl.com:  OK

让我们观察上面代码的细节,其中有一些精妙的知识点需要了解。

新线程由 Thread.new 创建。伴随创建时编写的代码块表示着新线程运行时将会执行的代码。在我们例子中,代码块通过 net/http 库从我们定义的网站获取首页数据。我们也清晰地记录了对于这些网页的平行摘取结果。

当创建线程时,我们将需要的 HTML 页面作为入参传递。这个入参被传递至代码块内作为 myPage。为什么我们这样做,而不是直接在代码块中使用 page 变量?

线程共享在线程启动域中的所有全局变量,实例变量和局部变量。任何有兄弟姐妹的人都会告诉你,分享不一定是件好事。在这个例子中,如果三个线程都共享 page 变量。第一个线程启动时,page 被置为 http://www.rubycentral.com。同时,循环创建的其他线程也开始运行。第二次时 page 被置为 http://www.awl.com。如果第一个线程还没有结束对 page 变量的使用,此时它使用的 page 将会是新值。这样的问题是很难跟踪的。

不过,在线程代码块中创建的局部变量对线程来说是真正局部私有的,每个线程都将拥有这些变量的拷贝。在我们的例子中,myPage 变量将归属于同时被创建的线程,每个线程也都将拥有属于自己的网页拷贝。

操作线程

在上个例子中应该有人敏锐地关注到了最后一行代码。为什么我们需要让每个创建的线程调用 join 方法?

当一个 Ruby 程序停止时,所有运行的线程将会被杀死,不论这些线程当前处于什么状态。然而,你可以通过调用 Thread#join 方法使程序等待一个特殊线程结束后再停止。只有提供的线程完成后调用的特殊线程才会停止。通过对每个请求者线程调用 join 方法,你可以确认所有的三个请求在主程序停止前都已经完成。

关于 join 还需要提到一点,我们需要通过一些其他的例程来操作线程。首先,当前线程总是通过 Thread.current 方法访问。你也可以通过 Thread.list 获取线程列表,这个列表中有所有可运行的和已经停止的 Thread 对象。你可以用 Thread#statusThread#alive? 确认指定线程的状态。

你也可以用 Thread#priority= 调整线程的权重。高权重的线程将会在低权重的线程之前运行。我们还会讨论线程调度,停止和启动线程等等。

线程变量

如同上一部分我们提到的一样,一个线程可以正常地访问被创建时作用域中的变量。归属于线程代码块的变量只归属于此线程,并且不能共享。

但是如果我需要每个线程中的变量能被其他线程(包括主线程)访问时要怎么办?Thread 有一个特殊能力,它允许线程本地变量能被创建以及通过名字访问。你可以简单地将线程对象视作 Hash,并通过 []= 写入元素和通过 [] 读取元素。在这个例子中,每个线程都以本地变量的形式记录了 count 变量的当前值,并且 count 变量以 mycount 作为键。(这段代码中有一个竞态条件,但我们还没有讲过同步问题,所以我们现在暂时先忽略它)。

count = 0
arr = []
10.times do |i|
  arr[i] = Thread.new {
    sleep(rand(0)/10.0)
    Thread.current["mycount"] = count
    count += 1
  }
end
arr.each {|t| t.join; print t["mycount"], ", " }
puts "count = #{count}"

结果是:

8, 0, 3, 7, 2, 1, 6, 5, 4, 9, count = 10

主线程会等待子线程完成然后打印每个线程中捕获的 count 值。为了让例子更加有趣,在记录数值之前我们让每个线程进行一个随机时间的等待。

线程与异常

如果线程抛出了没有处理的异常会发生什么?这种情况需要依赖于 abort_on_exception 标识的设置,关于具体情况的内容在 384 和 387 页。

abort_on_exception 默认条件是 false,这种情况下未处理的异常会直接杀掉当前线程,剩下的所有线程还将继续运转。在下面的例子中,线程 3 将崩溃并且不会再产生任何输出内容。不过,你依然可以观察到其他线程的记录。

threads = []
6.times { |i|
  threads << Thread.new(i) {
    raise "Boom!" if i == 3
    puts i
  }
}
threads.each {|t| t.join }

结果是:

0
1
2
4
5
prog.rb:4: Boom! (RuntimeError)
from prog.rb:80:in `join'
from prog.rb:8
from prog.rb:8:in `each'
from prog.rb:80

不过,将 abort_on_exception 设置为 true 时,未处理的异常将导致所有运行的线程被杀掉。一旦线程 3 死亡就不会再有任何线程的输出出现。

Thread.abort_on_exception = true
threads = []
6.times { |i|
  threads << Thread.new(i) {
    raise "Boom!" if i == 3
    puts i
  }
}
threads.each {|t| t.join }

结果是:

0
1
2
prog.rb:5: Boom! (RuntimeError)
from prog.rb:7:in `initialize'
from prog.rb:7:in `new'
from prog.rb:7
from prog.rb:3:in `times'
from prog.rb:3'

控制线程调度器

在设计良好的应用中,你通常让线程做自己的事情,在多线程应用中构建时序依赖通常被认为是种糟糕形式。

不过,有时线程也需要控制。或许点唱机中有一个线程进行灯光展示。当音乐停止时我们需要让它也暂停。你可能会按照生产者-消费者模式(如果生产者产品积压订单消费者必须暂停)管理两个线程。

Thread 类提供了一些方法控制线程调度器。可以调用 Thread.stop 停止线程,而 Thread#run 是指派特定线程进行运行。Thread.pass 通过调度当前线程让其他线程运行,并且 Thread#joinThread#value 会推迟线程的调用,直到被提供的线程结束。

我们会在下面这个完全没有意义的例子中展示这些特性。

t = Thread.new { sleep .1; Thread.pass; Thread.stop; }
t.status      »"sleep"
t.run
t.status      »"run"
t.run
t.status      »false

然而,通过这些基本方式要获得真正的同步性最多成败各半,并且还总有竞态条件等着攻击你一下。而且当你是基于共享数据运转时,竞态条件会保持较长时间,同时也会带来令人沮丧的调试情况。所幸,线程还有一个额外功能是临界区的概念。通过这个能力,我们可以构建一些安全的同步性方案。

互斥

阻止其他线程运行的最低级别方法是使用全局「线程边界」条件。当条件被设置为 true(使用 Thread.critical= 方法) 时,调度器不会再调度已经存在的线程进行运转。但是,这并不会阻止新线程被创建和运行。某些线程操作(比如,停止或杀掉线程,使当前线程休眠或抛出异常)会导致在临界情况下线程也会被调度。

直接使用 Thread.critical= 确实也可行,不过并不是太方便。幸运地是,Ruby 库中还有几个实现类似功能的方法。这些方法中有两个比较好用,分别是 Mutex 类和 ConditionVariable 类,它们都是作为 thread 库中的模块使用,可以从 457 页文档开始了解相关内容。

Mutex 类

Mutex 是一个实现了互斥访问共享资源基本信号锁的类。也就是说,在同一给定时间只能有一个线程持有锁。其他线程可能会选择排队等待锁,也可能选择获取一个指明锁不可用的即时错误。

当需要原子更新共享数据时互斥通常会被使用。比如说我们需要更新一个交易中的两个变量。我们可以通过一个能够添加计数器的程序模拟这个过程。这个更新假设是原子性的,真实世界也不太可能看到关于不同值的计数器。没有任何类型的互斥控制它将不会运转。

ount1 = count2 = 0
difference = 0
counter = Thread.new do
  loop do
    count1 += 1
    count2 += 1
  end
end
spy = Thread.new do
  loop do
    difference += (count1 - count2).abs
  end
end
sleep 1
Thread.critical = 1
count1                    »184846
count2                    »184846
difference                »58126

通过这个例子可以看到 spy 线程被唤醒了许多次,并且还有 count1count2 不一致的值。

所幸我们还可以用互斥锁解决这个问题。


require 'thread'
mutex = Mutex.new

count1 = count2 = 0
difference = 0
counter = Thread.new do
  loop do
    mutex.synchronize do
      count1 += 1
      count2 += 1
    end
  end
end
spy = Thread.new do
  loop do
    mutex.synchronize do
      difference += (count1 - count2).abs
    end
  end
end
sleep 1
mutex.lock
count1           »21192
count2           »21192
difference       »0

通过将所有共享数据的访问都置于互斥锁的控制下数据得以保持了一致性。不太圆满的是,你可以从计数结果上看出程序的性能受到了一定的影响。

条件变量

使用互斥锁保护临界数据有时并不足够。假如你处于临界情形中,但是你需要等待一些特殊资源时。如果你的线程是通过休眠方式等待资源,由于线程不能进入临界区(比如原始进程将它锁定的时候)就有可能导致它无法释放资源。你需要临时性放弃排他性地使用临界区域,同时告诉大家你正在等候资源。当资源可以使用时,你需要同时获取资源和锁。

这正是条件变量的用武之地。一个条件变量就是一个信号,这个信号表明资源已经被分配并且被互斥锁保护。当你需要不可用的资源时你需要等待一个条件变量。这个行为会使相应的互斥锁将锁释放。当一些其他线程信号表明资源可用时,原始线程将结束等待并立即获取临界区上的锁。

require 'thread'
mutex = Mutex.new
cv = ConditionVariable.new

a = Thread.new {
  mutex.synchronize {
    puts "A: I have critical section, but will wait for cv"
    cv.wait(mutex)
    puts "A: I have critical section again! I rule!"
  }
}

puts "(Later, back at the ranch...)"

b = Thread.new {
  mutex.synchronize {
    puts "B: Now I am critical, but am done with cv"
    cv.signal
    puts "B: I am still critical, finishing up"
  }
}
a.join
b.join

结果是:

(Later, back at the ranch...)
A: I have critical section, but will wait for cv
B: Now I am critical, but am done with cv
B: I am still critical, finishing up
A: I have critical section again! I rule!

关于同步性方案的选择可以参考 monitor.rbsync.rb,它们都分布在 lib 子路径中。

运行多进程

有时你想要将任务分解为进程级别运行,或者需要分别运行不是通过 Ruby 编写的进程。这不是问题,Ruby 有一些方法可以创建和管理进程。

生成新进程

有几种方式都可以创建进程,最简单的就是运行命令并等待它运行结束。你可能会发现你能够通过这种方式运行独立的命令或者从主机系统获取数据。Ruby 可以通过 system 和反引号实现这个功能。

system("tar xzf test.tgz")»tar: test.tgz: Cannot open: No such file or directory\ntar: Error is not recoverable: exiting now\ntar: Child returned status 2\ntar: Error exit delayed from previous errors\nfalse
result = `date`
result»"Sun Jun  9 00:08:50 CDT 2002\n"

Kernel::system 方法通过一个子进程执行提供的命令,如果命令被确认并能够正常执行将返回 true,否则就会返回 false。上面的失败例子中,你可以通过全局参数 $? 获取子进程的退出码。

使用 system 的一个问题就是运行命令输出的结果会和程序的输出结果打印到同一控制台,并不会被处理成你希望看到的样子。如果想捕获子进程的输出内容,你可以使用反引号,就像上面例子中的 date 一样。需要提醒的是,你可能需要通过 String#chomp 将结果中的换行符移出。

当然,对于简单例子也没有什么复杂之处,我们可以运行其他进程并获取返回的状态。不过很多时候我们就会需要更多的控制。我们想要担负起与子进程的交流,这时候我们可能需要传递数据并获取响应。IO.popen 方法就为此而生。popen 方法将命令作为子进程运行,并将子进程的输入输出联结至 Ruby 的 IO 对象。向 IO 对象写入内容,然后子进程就可以通过标准输入读取它。无论子进程输出什么,只要是 Ruby 规则允许的都可以通过 IO 对象读取。

例如,在我们系统中有一个比较有用的工具就是 pig,这个程序可以从标准输入中读取文字并将它们用特殊方式打印出来。当我们的 Ruby 程序需要传递我们的输出内容时也可以通过这种方式,不过特殊打印的内容要花很长的时间才可能读懂。

pig = IO.popen("pig", "w+")
pig.puts "ice cream after they go to bed"
pig.close_write
puts pig.gets

结果是:

iceway eamcray afterway eythay ogay otay edbay

这个例子说明的情况比较简单,现实生活中使用子进程的管道时会包含更加复杂的情况。例子中的代码看起来比较简单,过程就是开启管道,再写入短语,最后获取响应。但是 pig 程序并没有刷新它的所有输出。有关这个例子起初我们的打算是在 pig.gets 之后还能运行 pig.puts,程序能一直运转下去。pig 程序获取了我们的输入,但是响应内容从没有写入管道中。我们必须插入 pig.close_write 行,相当于给 pig 的标准输入传递了文件结束标识,我们才可以获取作为 pig 结束时刷新的输出内容。

关于 popen 还有额外有一些知识。如果传递的命令是一个单独的减号的话,popen 会启用一个新的 Ruby 解释器。新的解释器和原有的解释器都会通过 popen 的返回结果继续运行。原进程将获取一个 IO 对象,而子进程将获得 nil

pipe = IO.popen("-","w+")
if pipe
  pipe.puts "Get a job!"
  $stderr.puts "Child says '#{pipe.gets.chomp}'"
else
  $stderr.puts "Dad says '#{gets.chomp}'"
  puts "OK"
end

结果是:

Dad says 'Get a job!'
Child says 'OK'

关于 popen 还有点需要说明,传统的 Unix 系统中调用 Kernel::forkKernel::execIO.pipe 都可以被平台支持。许多 IO 方法为了文件命名约定 Kernel::open 也可以创建子进程,不过你需要将「|」作为文件名的第一个字符(具体内容可以查看 325 页的 IO 类详细内容)。需要注意,你并不能通过 File.new 创建管道,它只能对文件使用。

独立的子进程

有时我们并不需要亲自做这些,我们希望子进程可以自己管理,我们可以继续进行我们的业务流程。稍后,我们只要检查一下子进程是否结束就可以。关于这个实际问题,我们应该要开始了解一下长时间运行的外部排序。

exec("sort testfile > output.txt") if fork == nil
# The sort is now running in a child process
# carry on processing in the main program

# then wait for the sort to finish
Process.wait

调用 Kernel::fork 将向父进程返回一个进程 id,并向子进程返回 nil,因此子进程会执行 Kernel::exec 并运行排序。接着,我们会调用 Process::wait 以防不测,它会等待整个排序完成(也会返回进程 id)。

如果你更希望在子进程退出时被提醒(而不只是等待),你可以通过 Kernel::trap 设置信号处理器(在 427 页有详细讲述)。在 SIGCLD 上我们会设置一个警报器,它会发出「子进程已经死亡」的信号。

trap("CLD") {
  pid = Process.wait
  puts "Child pid #{pid}: terminated"
  exit
}

exec("sort testfile > output.txt") if fork == nil

# do other stuff...

结果是:

Child pid 31842: terminated

代码块和子进程

IO.popen 通过一种和 File.open 很相似的方式使用代码块。比如向 popen 传递一个 date 命令,相应代码块的参数将会是 IO 对象。

IO.popen ("date") { |f| puts "Date is #{f.gets}" }

结果是:

Date is Sun Jun  9 00:08:50 CDT 2002

当代码块退出时 IO 对象将自动关闭,就如同 File.open 一样。

如果你通过 Kernel::fork 关联上代码块,代码块中的代码将在子进程中运行,父进程也会在代码块运行完成后继续运行。

fork do
  puts "In child, pid = #$$"
  exit 99
end
pid = Process.wait
puts "Child terminated, pid = #{pid}, exit code = #{$? >> 8}"

结果是:

In child, pid = 31849
Child terminated, pid = 31849, exit code = 99

最后要说的就是,为什么我们在展示退出码前要将 $? 右移 8 位呢?这是 Posix 系统的特性,退出码的后 8 位包含了程序退出原因,只有前 8 位才是真实的退出码。


本文翻译自《Programming Ruby》,主要目的是自己学习使用,文中翻译不到位之处烦请指正,如需转载请注明出处

本章原文为 Threads and Processes

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值