Ruby 线程与并发编程全解析
1. 线程与未处理异常
在 Ruby 中,线程的异常处理机制与主线程有所不同。当主线程抛出未处理的异常时,Ruby 解释器会打印消息并退出。而对于非主线程,未处理的异常只会使该线程停止运行,默认情况下不会导致解释器打印消息或退出。
若线程
t
因未处理的异常退出,而另一个线程
s
调用
t.join
或
t.value
,则
t
中发生的异常会在
s
线程中抛出。
如果希望任何线程中的未处理异常都能导致解释器退出,可以使用类方法
Thread.abort_on_exception = true
。若仅希望某个特定线程的未处理异常导致解释器退出,则使用同名的实例方法:
t = Thread.new { ... }
t.abort_on_exception = true
2. 线程与变量
2.1 线程共享变量
线程的一个关键特性是它们可以共享对变量的访问。由于线程由代码块定义,它们可以访问代码块作用域内的任何变量,包括局部变量、实例变量和全局变量等。示例如下:
x = 0
t1 = Thread.new do
# 此线程可以查询和设置变量 x
end
t2 = Thread.new do
# 此线程也可以查询和设置 x
# 并且它还可以查询和设置 t1 和 t2
end
当两个或多个线程同时读写相同的变量时,必须确保操作的正确性,这将在后续的线程同步部分详细讨论。
2.2 线程私有变量
在线程代码块内定义的变量是该线程私有的,其他线程不可见,这是 Ruby 变量作用域规则的结果。
例如,以下代码尝试创建三个线程,分别打印数字 1、2 和 3:
n = 1
while n <= 3
Thread.new { puts n }
n += 1
end
在某些情况下,这段代码可能按预期工作并打印 1、2 和 3,但在其他情况下可能不会。因为每个线程读取的是变量
n
的共享副本,而该变量的值会随着循环的执行而改变,线程打印的值取决于该线程相对于父线程的运行时间。
为了解决这个问题,可以将
n
的当前值传递给
Thread.new
方法,并将该变量的当前值赋给一个块参数:
n = 1
while n <= 3
# 获取 n 当前值的私有副本到 x
Thread.new(n) {|x| puts x }
n += 1
end
另一种解决方法是使用迭代器而不是
while
循环:
1.upto(3) {|n| Thread.new { puts n }}
2.3 线程局部变量
Ruby 的某些特殊全局变量是线程局部的,不同线程中它们可能有不同的值,例如
$SAFE
和
$~
。这意味着如果两个线程同时进行正则表达式匹配,它们将看到不同的
$~
值,一个线程的匹配操作不会干扰另一个线程的匹配结果。
Thread
类提供了类似哈希的行为,定义了
[]
和
[]=
实例方法,允许将任意值与任何符号关联。以下是一个示例,假设创建了一些线程从 Web 服务器下载文件,主线程需要监控下载进度:
# 每个线程设置进度
Thread.current[:progress] = bytes_received
# 主线程计算总下载字节数
total = 0
download_threads.each {|t| total += t[:progress] if t.key?(:progress)}
3. 线程调度
3.1 线程优先级
影响线程调度的第一个因素是线程优先级,高优先级线程会在低优先级线程之前被调度。更准确地说,只有在没有更高优先级线程等待运行时,线程才能获得 CPU 时间。
可以使用
priority=
和
priority
方法来设置和查询 Ruby
Thread
对象的优先级,但在线程启动之前无法设置其优先级。新创建的线程的优先级与创建它的线程相同,主线程的初始优先级为 0。
线程优先级依赖于 Ruby 的实现和底层操作系统,例如在 Linux 上,非特权线程无法提高或降低其优先级,因此在使用原生线程的 Ruby 1.9 中,线程优先级设置会被忽略。
3.2 线程抢占和
Thread.pass
当多个相同优先级的线程需要共享 CPU 时,由线程调度器决定每个线程何时运行以及运行多长时间。有些调度器是抢占式的,即允许线程运行一段固定时间后,再让另一个相同优先级的线程运行;而其他调度器是非抢占式的,一旦线程开始运行,它将继续运行,除非它进入睡眠状态、进行 I/O 阻塞或有更高优先级的线程唤醒。
如果一个长时间运行的计算密集型线程在非抢占式调度器上运行,它可能会“饿死”其他相同优先级的线程。为避免这种情况,长时间运行的计算密集型线程应定期调用
Thread.pass
方法,请求调度器将 CPU 让给其他线程。
4. 线程状态
4.1 线程的五种状态
Ruby 线程可能处于以下五种状态之一:
| 线程状态 | 返回值 |
| ---- | ---- |
| 可运行 | “run” |
| 睡眠 | “sleep” |
| 中止 | “aborting” |
| 正常终止 | false |
| 异常终止 | nil |
4.2 查询线程状态
Thread
类定义了几个实例方法来测试线程的状态:
-
alive?
:如果线程可运行或睡眠,则返回
true
。
-
stop?
:如果线程处于除可运行之外的任何状态,则返回
true
。
-
status
:返回线程的状态。
4.3 改变线程状态
线程创建时处于可运行状态,可通过调用
Thread.stop
方法暂停自身,进入睡眠状态。调用
Kernel.sleep
方法并传入参数也会使线程暂时进入睡眠状态,经过指定的秒数后自动唤醒并重新进入可运行状态。
可以使用
wakeup
和
run
方法将暂停的线程重新启动,
run
方法还会调用线程调度器,可能使新唤醒的线程立即开始运行,而
wakeup
方法唤醒指定线程但不放弃 CPU。
线程可以通过退出其代码块或抛出异常从可运行状态转换到终止状态,也可以调用
Thread.exit
方法正常终止。此外,一个线程可以通过调用另一个线程的
kill
、
terminate
或
exit
方法强制终止它,
kill!
、
terminate!
和
exit!
方法会终止线程但不允许执行
ensure
子句。还可以使用
raise
方法在另一个线程中抛出异常。
以下是线程状态转换的 mermaid 流程图:
graph LR
classDef startend fill:#F5EBFF,stroke:#BE8FED,stroke-width:2px;
classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px;
classDef decision fill:#FFF6CC,stroke:#FFBC52,stroke-width:2px;
A([创建线程]):::startend --> B(可运行状态):::process
B --> C{操作}:::decision
C -->|Thread.stop| D(睡眠状态):::process
C -->|Kernel.sleep| D
C -->|退出代码块/抛出异常| E(终止状态):::process
C -->|Thread.exit| E
D -->|wakeup/run| B
B -->|kill/terminate/exit| E
B -->|raise 异常| E
5. 线程列表和线程组
5.1 列出所有活动线程
Thread.list
方法返回一个表示所有活动(运行或睡眠)线程的
Thread
对象数组,当线程退出时,它将从该数组中移除。
5.2 线程组
可以创建
ThreadGroup
对象并将线程添加到其中,对线程进行分组管理。新线程最初会被放置在其父线程所属的组中,可以使用
group
方法查询线程所属的
ThreadGroup
,使用
list
方法获取组中的线程数组。
ThreadGroup
类的
enclose
方法使其比简单的线程数组更有用,一旦线程组被封闭,就不能从中移除线程,也不能添加新线程,但组内的线程可以创建新线程,新线程将成为该组的成员。以下是一个示例:
group = ThreadGroup.new
3.times {|n| group.add(Thread.new { do_task(n) }}
6. 线程应用示例
6.1 并发读取文件
在处理 IO 密集型任务时,Ruby 的线程可以让程序在等待用户输入、文件系统或网络数据时保持忙碌。下面的
conread
方法可以并发读取多个文件,并返回一个将文件名映射到文件内容的哈希表:
# Read files concurrently. Use with the "open-uri" module to fetch URLs.
# Pass an array of filenames. Returns a hash mapping filenames to content.
def conread(filenames)
h = {} # Empty hash of results
# Create one thread for each file
filenames.each do |filename| # For each named file
h[filename] = Thread.new do # Create a thread, map to filename
open(filename) {|f| f.read } # Open and read the file
end # Thread value is file contents
end
# Iterate through the hash, waiting for each thread to complete.
# Replace the thread in the hash with its value (the file contents)
h.each_pair do |filename, thread|
begin
h[filename] = thread.value # Map filename to file contents
rescue
h[filename] = $! # Or to the exception raised
end
end
end
操作步骤如下:
1. 初始化一个空的哈希表
h
用于存储结果。
2. 遍历文件名数组,为每个文件创建一个线程,线程的任务是打开并读取文件,线程的值即为文件内容。
3. 再次遍历哈希表,等待每个线程完成,并将线程对象替换为其值(文件内容),若线程抛出异常,则将异常信息存储在哈希表中。
6.2 多线程服务器
线程的另一个常见应用是编写可以同时与多个客户端通信的服务器。以下是一个简单的多线程服务器示例:
require 'socket'
# This method expects a socket connected to a client.
# It reads lines from the client, reverses them and sends them back.
# Multiple threads may run this method at the same time.
def handle_client(c)
while true
input = c.gets.chop # Read a line of input from the client
break if !input # Exit if no more input
break if input=="quit" # or if the client asks to.
c.puts(input.reverse) # Otherwise, respond to client.
c.flush # Force our output out
end
c.close # Close the client socket
end
server = TCPServer.open(2000) # Listen on port 2000
while true # Servers loop forever
client = server.accept # Wait for a client to connect
Thread.start(client) do |c| # Start a new thread
handle_client(c) # And handle the client on that thread
end
end
操作步骤如下:
1. 定义
handle_client
方法,用于处理与客户端的通信,读取客户端输入,反转字符串并发送回客户端,直到客户端输入
quit
或关闭连接。
2. 创建一个 TCP 服务器,监听端口 2000。
3. 进入无限循环,等待客户端连接,为每个客户端连接创建一个新线程,并在该线程中调用
handle_client
方法处理客户端请求。
6.3 并发迭代器
除了 IO 密集型任务,线程还可以用于并发处理数组元素。以下是为
Enumerable
模块添加的
conmap
和
concurrently
方法:
module Enumerable # Open the Enumerable module
def conmap(&block) # Define a new method that expects a block
threads = [] # Start with an empty array of threads
self.each do |item| # For each enumerable item
# Invoke the block in a new thread, and remember the thread
threads << Thread.new { block.call(item) }
end
# Now map the array of threads to their values
threads.map {|t| t.value } # And return the array of values
end
end
module Enumerable
def concurrently
map {|item| Thread.new { yield item }}.each {|t| t.join }
end
end
操作步骤如下:
-
conmap 方法
:
1. 初始化一个空的线程数组
threads
。
2. 遍历可枚举对象的每个元素,为每个元素创建一个新线程,并在该线程中调用传入的块。
3. 等待所有线程完成,并将线程对象映射为其值,返回结果数组。
-
concurrently 方法
:
1. 为可枚举对象的每个元素创建一个新线程,并在该线程中调用传入的块。
2. 等待所有线程完成。
7. 线程互斥与死锁
当两个或多个线程共享对同一数据的访问,并且至少有一个线程修改该数据时,必须采取特殊措施确保没有线程会看到数据处于不一致的状态,这就是线程互斥。
7.1 线程互斥的必要性
以下是两个需要线程互斥的经典例子:
-
文件处理计数器
:两个线程处理文件并递增一个共享变量来跟踪处理的文件总数。由于递增操作不是原子操作,可能会导致计数器值错误。
-
电子银行应用
:一个线程处理从储蓄账户到支票账户的资金转移,另一个线程生成月度报告。如果没有适当的互斥机制,报告生成线程可能会读取到不一致的账户数据。
7.2 使用互斥锁(Mutex)
可以使用
Mutex
对象来实现线程互斥,以下是一个银行账户示例:
require 'thread' # For Mutex class in Ruby 1.8
# A BankAccount has a name, a checking amount, and a savings amount.
class BankAccount
def init(name, checking, savings)
@name,@checking,@savings = name,checking,savings
@lock = Mutex.new # For thread safety
end
# Lock account and transfer money from savings to checking
def transfer_from_savings(x)
@lock.synchronize {
@savings -= x
@checking += x
}
end
# Lock account and report current balances
def report
@lock.synchronize {
"#@name\nChecking: #@checking\nSavings: #@savings"
}
end
end
操作步骤如下:
1. 在
BankAccount
类中初始化一个
Mutex
对象
@lock
。
2. 在
transfer_from_savings
方法中,使用
@lock.synchronize
块来确保在资金转移过程中其他线程无法访问账户数据。
3. 在
report
方法中,同样使用
@lock.synchronize
块来确保在报告账户余额时数据的一致性。
通过以上内容,我们全面了解了 Ruby 线程的异常处理、变量共享、调度、状态管理、应用示例以及线程互斥等方面的知识,掌握这些知识可以帮助我们编写更高效、更可靠的并发程序。
超级会员免费看
81

被折叠的 条评论
为什么被折叠?



