47、线程与ZeroMQ:深入解析与实践

线程与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
  1. 创建一个名为 zeromq 的新虚拟环境:
(base) $ conda create --name zeromq python=3.9 pyzmq
  1. 激活新的虚拟环境:
(base) $ source activate zeromq
  1. 检查Python版本是否为3.9:
(zeromq) $ python --version
  1. 列出已安装在环境中的包:
(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有所帮助,在实际开发中能够灵活运用这些知识。

【2025年10月最新优化算法】混沌增强领导者黏菌算法(Matlab代码实现)内容概要:本文档介绍了2025年10月最新提出的混沌增强领导者黏菌算法(Matlab代码实现),属于智能优化算法领域的一项前沿研究。该算法结合混沌机制黏菌优化算法,通过引入领导者策略提升搜索效率和全局寻优能力,适用于复杂工程优化问题的求解。文档不仅提供完整的Matlab实现代码,还涵盖了算法原理、性能验证及其他优化算法的对比分析,体现了较强的科研复现性和应用拓展性。此外,文中列举了大量相关科研方向和技术应用场景,展示其在微电网调度、路径规划、图像处理、信号分析、电力系统优化等多个领域的广泛应用潜力。; 适合人群:具备一定编程基础和优化理论知识,从事科研工作的研究生、博士生及高校教师,尤其是关注智能优化算法及其在工程领域应用的研发人员;熟悉Matlab编程环境者更佳。; 使用场景及目标:①用于解决复杂的连续空间优化问题,如函数优化、参数辨识、工程设计等;②作为新型元启发式算法的学习教学案例;③支持高水平论文复现算法改进创新,推动在微电网、无人机路径规划、电力系统等实际系统中的集成应用; 其他说明:资源包含完整Matlab代码和复现指导,建议结合具体应用场景进行调试拓展,鼓励在此基础上开展算法融合性能优化研究。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值