线程与ZeroMQ:深入解析与实践
1. 线程基础
线程的编程接口是POSIX线程API,也就是常说的pthreads,它最早在IEEE POSIX 1003.1c标准(1995年)中定义,作为libpthread.so C库的一部分实现。过去15年左右,pthreads有两种实现:LinuxThreads和Native POSIX Thread Library (NPTL)。NPTL更符合规范,特别是在信号和进程ID处理方面,现在占据主导地位,但可能会遇到使用LinuxThreads的旧版uClibc。
1.1 创建新线程
可以使用
pthread_create(3)
函数创建线程:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
该函数创建一个新的执行线程,从
start_routine
函数开始执行,并将线程描述符放入
pthread_t
中。新线程继承调用线程的调度参数,但可以通过
attr
指针覆盖这些参数。线程将立即开始执行。
以下是一个简单的示例程序,展示了线程的生命周期:
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/syscall.h>
static void *thread_fn(void *arg)
{
printf("New thread started, PID %d TID %d\n",
getpid(), (pid_t)syscall(SYS_gettid));
sleep(10);
printf("New thread terminating\n");
return NULL;
}
int main(int argc, char *argv[])
{
pthread_t t;
printf("Main thread, PID %d TID %d\n",
getpid(), (pid_t)syscall(SYS_gettid));
pthread_create(&t, NULL, thread_fn, NULL);
pthread_join(t, NULL);
return 0;
}
在
thread_fn
函数中,使用
syscall(SYS_gettid)
获取线程ID。在glibc 2.80之前,由于没有C库包装器,必须通过系统调用直接调用Linux。
每个内核能调度的线程总数是有限的,这个限制根据系统大小而定,小设备约为1000个,大型嵌入式设备可达数万个。实际数量可以在
/proc/sys/kernel/threads-max
中查看。达到这个限制后,
fork
和
pthread_create
将失败。
1.2 终止线程
线程在以下情况之一发生时终止:
- 到达
start_routine
的末尾。
- 调用
pthread_exit(3)
。
- 被另一个线程调用
pthread_cancel(3)
取消。
- 包含该线程的进程终止,例如,由于线程调用
exit(3)
,或者进程收到未处理、屏蔽或忽略的信号。
需要注意的是,如果多线程程序调用
fork
,只有调用该函数的线程会存在于新的子进程中,
fork
不会复制所有线程。
线程有一个返回值,是一个
void
指针。一个线程可以通过调用
pthread_join(2)
等待另一个线程终止并收集其返回值。如果线程未被
join
,会导致资源泄漏。
1.3 编译带线程的程序
POSIX线程支持是
libpthread.so
库的一部分。但构建带线程的程序不仅仅是链接库,编译器生成代码的方式也需要改变,以确保某些全局变量(如
errno
)每个线程有一个实例,而不是整个进程一个实例。
提示
:构建线程程序时,必须在编译和链接阶段添加
-pthread
开关。使用
-pthread
时,不需要在链接阶段再使用
-lpthread
。
2. 线程间通信
线程的主要优点是它们共享地址空间和内存变量,但这也是一个缺点,因为需要同步来保证数据一致性,类似于进程间共享内存段,但线程共享所有内存。
pthreads接口提供了实现同步所需的基本机制:互斥锁和条件变量。如果需要更复杂的结构,需要自己构建。
所有之前描述的IPC方法(如套接字、管道和消息队列)在同一进程的线程之间同样适用。
2.1 互斥
为了编写健壮的程序,需要用互斥锁保护每个共享资源,并确保每个读写该资源的代码路径首先锁定互斥锁。如果始终遵循这个规则,大多数问题应该可以解决。但仍然存在一些与互斥锁基本行为相关的问题:
-
死锁
:当互斥锁永久锁定时发生。经典情况是“死锁拥抱”,两个线程都需要两个互斥锁,并且都锁定了其中一个,但无法锁定另一个。每个线程都阻塞,等待另一个线程持有的锁,从而陷入僵局。避免“死锁拥抱”问题的一个简单规则是确保互斥锁始终按相同顺序锁定。其他解决方案包括超时和回退期。
-
优先级反转
:等待互斥锁导致的延迟可能使实时线程错过最后期限。具体情况是,高优先级线程因等待低优先级线程锁定的互斥锁而阻塞。如果低优先级线程被中等优先级线程抢占,高优先级线程将被迫无限期等待。有一些互斥锁协议(如优先级继承和优先级上限)可以解决这个问题,但每次锁定和解锁调用会增加内核的处理开销。
-
性能不佳
:只要线程大部分时间不需要阻塞在互斥锁上,互斥锁对代码的开销就很小。但如果设计中有很多线程需要的资源,竞争率会变得很显著。这通常是设计问题,可以通过更细粒度的锁定或不同的算法来解决。
互斥锁不是线程间同步的唯一方式,线程也有类似进程使用信号量的机制。
2.2 条件变化
协作线程需要能够相互提醒某些事情发生了变化,需要关注。这称为条件,提醒通过条件变量(condvar)发送。
条件是可以测试得到真或假结果的东西。例如,一个缓冲区可能为空或包含一些项。一个线程从缓冲区取项,当缓冲区为空时休眠。另一个线程向缓冲区放入项,并向另一个线程发出信号,因为另一个线程等待的条件发生了变化。如果它正在休眠,需要唤醒并执行操作。唯一的复杂性在于,条件是共享资源,必须用互斥锁保护。
以下是一个包含两个线程的简单程序,一个是生产者,另一个是消费者:
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <string.h>
char g_data[128];
pthread_cond_t cv = PTHREAD_COND_INITIALIZER;
pthread_mutex_t mutx = PTHREAD_MUTEX_INITIALIZER;
void *consumer(void *arg)
{
while (1) {
pthread_mutex_lock(&mutx);
while (strlen(g_data) == 0)
pthread_cond_wait(&cv, &mutx);
/* Got data */
printf("%s\n", g_data);
/* Truncate to null string again */
g_data[0] = 0;
pthread_mutex_unlock(&mutx);
}
return NULL;
}
void *producer(void *arg)
{
int i = 0;
while (1) {
sleep(1);
pthread_mutex_lock(&mutx);
sprintf(g_data, "Data item %d", i);
pthread_mutex_unlock(&mutx);
pthread_cond_signal(&cv);
i++;
}
return NULL;
}
当消费者线程在条件变量上阻塞时,它持有锁定的互斥锁,这似乎会导致生产者线程下次尝试更新条件时发生死锁。为避免这种情况,
pthread_condwait(3)
在线程阻塞后解锁互斥锁,在唤醒线程并从等待返回之前再次锁定它。
3. 问题划分
构建系统时,可以遵循以下规则:
-
规则1
:将交互频繁的任务放在一起:将紧密协作的线程放在一个进程中,以最小化开销。
-
规则2
:不要把所有线程放在一个篮子里:将交互有限的组件放在不同的进程中,以提高弹性和模块化。
-
规则3
:不要在同一进程中混合关键和非关键线程:系统的关键部分(如机器控制程序)应尽可能简单,并以更严格的方式编写。即使其他进程失败,它也必须能够继续运行。如果有实时线程,它们必须是关键的,应该单独放在一个进程中。
-
规则4
:线程不应过于紧密:编写多线程程序时,不要因为是一体化程序而容易操作就将代码和变量混在一起。保持线程模块化,有明确的交互。
-
规则5
:不要认为线程是免费的:创建额外的线程很容易,但有成本,尤其是在协调它们的活动所需的额外同步方面。
-
规则6
:线程可以并行工作:线程可以在多核处理器上同时运行,提高吞吐量。如果有大型计算任务,可以为每个核心创建一个线程,充分利用硬件。有一些库可以帮助实现这一点,如OpenMP。不建议从头开始编写并行编程算法。
Android设计是一个很好的例子。每个应用程序是一个单独的Linux进程,有助于模块化内存管理,确保一个应用程序崩溃不会影响整个系统。进程模型也用于访问控制,一个进程只能访问其UID和GIDs允许的文件和资源。每个进程中有一组线程,包括管理和更新用户界面的线程、处理操作系统信号的线程、管理动态内存分配和释放Java对象的线程,以及至少两个使用Binder协议从系统其他部分接收消息的工作线程池。
综上所述,进程提供弹性,因为每个进程有受保护的内存空间,进程终止时,所有资源(包括内存和文件描述符)都会被释放,减少资源泄漏。另一方面,线程共享资源,可以通过共享变量轻松通信,并通过共享对文件和其他资源的访问进行协作。线程通过工作线程池和其他抽象实现并行性,在多核处理器中很有用。
4. ZeroMQ简介
套接字、命名管道和共享内存是进程间通信的方式,它们作为消息传递过程的传输层,构成了大多数非平凡应用程序。并发原语(如互斥锁和条件变量)用于管理共享访问和协调同一进程内线程的工作。多线程编程非常困难,套接字和命名管道也有自己的问题。因此,需要一个更高级的API来抽象异步消息传递的复杂细节,ZeroMQ应运而生。
ZeroMQ是一个异步消息库,类似于并发框架。它支持进程内、进程间、TCP和多播传输,以及各种编程语言(如C、C++、Go和Python)的绑定。这些绑定和ZeroMQ基于套接字的抽象允许团队在同一分布式应用程序中轻松混合编程语言。库中还内置了对常见消息模式(如请求/回复、发布/订阅和平行管道)的支持。ZeroMQ中的“零”代表零成本,“MQ”代表消息队列。
5. 安装ZeroMQ for Python
我们将使用ZeroMQ的官方Python绑定进行以下练习。建议在新的虚拟环境中安装
pyzmq
包。如果系统中已经有
conda
,创建Python虚拟环境很容易。以下是使用
conda
配置必要虚拟环境的步骤:
1. 导航到包含示例的
zeromq
目录:
(base) $ cd MELP/Chapter17/zeromq
-
创建一个名为
zeromq的新虚拟环境:
(base) $ conda create --name zeromq python=3.9 pyzmq
- 激活新的虚拟环境:
(base) $ source activate zeromq
- 检查Python版本是否为3.9:
(zeromq) $ python --version
- 列出已安装在环境中的包:
(zeromq) $ conda list
如果在包列表中看到
pyzmq
及其依赖项,就可以运行以下练习了。
6. 进程间消息传递
我们从一个简单的回显服务器开始探索ZeroMQ。服务器期望从客户端收到一个字符串形式的名称,并回复
Hello <name>
。服务器代码如下:
import time
import zmq
context = zmq.Context()
socket = context.socket(zmq.REP)
socket.bind("tcp://*:5555")
while True:
# Wait for next request from client
message = socket.recv()
print(f"Received request: {message}")
# Do some 'work'
time.sleep(1)
# Send reply back to client
socket.send(b"Hello {message}")
服务器进程创建一个
REP
类型的套接字用于响应,将该套接字绑定到端口5555,并等待消息。使用1秒的睡眠来模拟在收到请求和发送回复之间进行的一些工作。
客户端代码如下:
import zmq
def main(who):
context = zmq.Context()
# Socket to talk to server
print("Connecting to hello echo server…")
socket = context.socket(zmq.REQ)
socket.connect("tcp://localhost:5555")
# Do 5 requests, waiting each time for a response
for request in range(5):
print(f"Sending request {request} …")
socket.send(b"{who}")
# Get the reply.
message = socket.recv()
print(f"Received reply {request} [ {message} ]")
if __name__ == '__main__':
import sys
if len(sys.argv) != 2:
print("usage: client.py <username>")
raise SystemExit
main(sys.argv[1])
客户端进程将用户名作为命令行参数。客户端创建一个
REQ
类型的套接字用于请求,连接到监听端口5555的服务器进程,并开始发送包含传入用户名的消息。与服务器中的
socket.recv()
一样,客户端中的
socket.recv()
会阻塞,直到队列中有消息到达。
要运行回显服务器和客户端代码,激活
zeromq
虚拟环境,从
MELP/Chapter17/zeromq
目录运行
planets.sh
脚本:
(zeromq) $ ./planets.sh
planets.sh
脚本会启动三个客户端进程:Mars、Jupiter和Venus。可以看到三个客户端的请求是交错的,因为每个客户端在发送下一个请求之前等待服务器的回复。由于每个客户端发送5个请求,应该从服务器收到总共15个回复。使用ZeroMQ进行基于消息的IPC非常容易。
7. 进程内消息传递
Python 3.4版本引入了
asyncio
模块,它添加了一个可插拔的事件循环,用于使用协程执行单线程并发代码。Python中的协程(也称为绿色线程)使用
async/await
语法声明,该语法借鉴自C#。它们比POSIX线程轻量级得多,更像可恢复的函数。由于协程在事件循环的单线程上下文中操作,可以将
pyzmq
与
asyncio
结合使用进行进程内基于套接字的消息传递。
以下是一个从
https://github.com/zeromq/pyzmq
仓库中获取的协程示例的略微修改版本:
import time
import zmq
from zmq.asyncio import Context, Poller
import asyncio
url = 'inproc://#1'
ctx = Context.instance()
async def receiver():
"""receive messages with polling"""
pull = ctx.socket(zmq.PAIR)
pull.connect(url)
poller = Poller()
poller.register(pull, zmq.POLLIN)
while True:
events = await poller.poll()
if pull in dict(events):
print("recving", events)
msg = await pull.recv_multipart()
print('recvd', msg)
async def sender():
"""send a message every second"""
tic = time.time()
push = ctx.socket(zmq.PAIR)
push.bind(url)
while True:
print("sending")
await push.send_multipart([str(time.time() - tic).encode('ascii')])
await asyncio.sleep(1)
asyncio.get_event_loop().run_until_complete(
asyncio.wait(
[
receiver(),
sender(),
]
)
)
receiver()
和
sender()
协程共享相同的上下文。套接字的
url
部分指定的
inproc
传输方法用于线程间通信,比之前示例中使用的
tcp
传输快得多。
PAIR
模式专门连接两个套接字。与
inproc
传输一样,这种消息模式仅在进程内工作,用于线程间的信号传递。
receiver()
和
sender()
协程都不会返回,
asyncio
事件循环在两个协程之间交替,在阻塞或完成I/O时暂停和恢复每个协程。
要从激活的
zeromq
虚拟环境中运行协程示例,使用以下命令:
(zeromq) $ python coroutines.py
通过以上内容,我们深入了解了线程的创建、终止、通信等基础知识,以及如何使用ZeroMQ进行进程间和进程内的消息传递。这些知识对于编写高效、并发的程序非常有帮助。
线程与ZeroMQ:深入解析与实践
8. 线程和ZeroMQ的应用场景分析
在实际开发中,线程和ZeroMQ的结合可以应用于多种场景,下面我们来详细分析一些常见的应用场景:
8.1 实时数据处理
在实时数据处理系统中,需要对大量的实时数据进行快速处理和分析。可以使用多线程来并行处理不同的数据块,提高处理速度。同时,ZeroMQ可以用于在不同线程或进程之间传递数据。
例如,一个实时监控系统需要处理来自多个传感器的数据流。可以为每个传感器创建一个线程来接收和初步处理数据,然后使用ZeroMQ将处理后的数据发送到一个中央处理线程进行进一步的分析和存储。
graph LR
classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px;
S1(传感器1):::process --> T1(线程1):::process
S2(传感器2):::process --> T2(线程2):::process
S3(传感器3):::process --> T3(线程3):::process
T1 --> ZMQ(ZeroMQ):::process
T2 --> ZMQ
T3 --> ZMQ
ZMQ --> CT(中央处理线程):::process
CT --> DB(数据库):::process
8.2 分布式系统开发
在分布式系统中,不同的节点之间需要进行高效的通信和协作。ZeroMQ可以提供可靠的消息传递机制,而线程可以用于处理节点内部的并发任务。
例如,一个分布式计算系统由多个计算节点和一个管理节点组成。计算节点可以使用多线程来并行执行计算任务,然后通过ZeroMQ将计算结果发送给管理节点。管理节点可以使用线程来处理来自不同计算节点的消息,并进行任务调度和结果汇总。
| 节点类型 | 功能 | 线程使用 | ZeroMQ使用 |
|---|---|---|---|
| 计算节点 | 执行计算任务 | 多线程并行计算 | 发送计算结果到管理节点 |
| 管理节点 | 任务调度和结果汇总 | 线程处理消息 | 接收计算结果 |
8.3 网络服务开发
在网络服务开发中,需要处理大量的并发请求。可以使用多线程来处理不同的请求,同时使用ZeroMQ来实现服务之间的通信。
例如,一个Web服务可以使用多线程来处理不同用户的请求,然后通过ZeroMQ将请求转发到后端的处理服务。后端处理服务可以使用线程来处理请求,并将处理结果通过ZeroMQ返回给Web服务。
graph LR
classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px;
U(用户):::process --> WS(Web服务):::process
WS --> T1(线程1):::process
WS --> T2(线程2):::process
T1 --> ZMQ1(ZeroMQ):::process
T2 --> ZMQ1
ZMQ1 --> BS(后端服务):::process
BS --> T3(线程3):::process
BS --> T4(线程4):::process
T3 --> ZMQ2(ZeroMQ):::process
T4 --> ZMQ2
ZMQ2 --> WS
WS --> U
9. 线程和ZeroMQ的性能优化
在使用线程和ZeroMQ时,为了提高系统的性能,需要进行一些优化。
9.1 线程池的使用
创建和销毁线程会带来一定的开销,特别是在频繁创建和销毁线程的情况下。可以使用线程池来管理线程,避免不必要的开销。
例如,在Python中可以使用
concurrent.futures
模块来创建线程池:
import concurrent.futures
def task_function(task):
# 任务处理逻辑
return result
# 创建线程池
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
# 提交任务
results = list(executor.map(task_function, tasks))
9.2 ZeroMQ的调优
ZeroMQ提供了一些参数可以进行调优,以提高消息传递的性能。例如,可以调整套接字的缓冲区大小、发送和接收的超时时间等。
import zmq
context = zmq.Context()
socket = context.socket(zmq.REP)
# 设置套接字选项
socket.setsockopt(zmq.SNDBUF, 1048576) # 设置发送缓冲区大小为1MB
socket.setsockopt(zmq.RCVBUF, 1048576) # 设置接收缓冲区大小为1MB
socket.setsockopt(zmq.SNDTIMEO, 1000) # 设置发送超时时间为1秒
socket.setsockopt(zmq.RCVTIMEO, 1000) # 设置接收超时时间为1秒
socket.bind("tcp://*:5555")
9.3 减少锁的使用
在多线程编程中,锁的使用会带来一定的性能开销,特别是在高并发的情况下。可以尽量减少锁的使用,或者使用更细粒度的锁。
例如,在前面的生产者 - 消费者示例中,可以使用无锁数据结构来减少锁的使用:
import queue
import threading
# 创建无锁队列
data_queue = queue.Queue()
def producer():
while True:
# 生产数据
data = "Data item"
data_queue.put(data)
def consumer():
while True:
# 消费数据
data = data_queue.get()
print(data)
# 创建线程
producer_thread = threading.Thread(target=producer)
consumer_thread = threading.Thread(target=consumer)
# 启动线程
producer_thread.start()
consumer_thread.start()
10. 总结
线程和ZeroMQ是并发编程中非常重要的工具。线程可以实现并发执行,提高系统的处理能力;ZeroMQ可以提供高效的消息传递机制,简化分布式系统的开发。
在使用线程时,需要注意线程的创建、终止和同步,避免出现死锁、资源泄漏等问题。在使用ZeroMQ时,需要根据不同的应用场景选择合适的消息模式,并进行性能调优。
通过合理地使用线程和ZeroMQ,可以开发出高效、可靠的并发程序,满足各种复杂的应用需求。希望本文对你理解线程和ZeroMQ有所帮助,在实际开发中能够灵活运用这些知识。
超级会员免费看
1133

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



