40、创建 TCP 服务器与多线程编程指南

创建 TCP 服务器与多线程编程指南

1. 创建 TCP 服务器

在网络编程中,创建一个 TCP 服务器是常见的任务。下面以一个建筑服务 TCP 服务器为例,详细介绍其实现过程。

1.1 服务器组件

建筑服务 TCP 服务器主要有三个组件:
- GUI :用于持有 TCP 服务器实例,并提供一种简单的方式让用户终止服务器。
- QTcpServer 子类 :实例化后提供服务器实例。
- QTcpSocket 子类 :用于处理传入的连接。

1.2 代码实现
import collections
from PyQt4.QtCore import *
from PyQt4.QtGui import *
from PyQt4.QtNetwork import *
import datetime
import bisect

# 假设的常量定义
PORT = 9407
SIZEOF_UINT16 = 2
MAX_BOOKINGS_PER_DAY = 5

# 存储预订数据的默认字典
Bookings = collections.defaultdict(list)

class BuildingServicesDlg(QPushButton):
    def __init__(self, parent=None):
        super(BuildingServicesDlg, self).__init__("&Close Server", parent)
        self.setWindowFlags(Qt.WindowStaysOnTopHint)
        self.loadBookings()
        self.tcpServer = TcpServer(self)
        if not self.tcpServer.listen(QHostAddress("0.0.0.0"), PORT):
            QMessageBox.critical(self, "Building Services Server",
                                 QString("Failed to start server: %1").arg(self.tcpServer.errorString()))
            self.close()
            return
        self.connect(self, SIGNAL("clicked()"), self.close)

    def loadBookings(self):
        # 此方法用于填充内存中的数据结构,暂不详细实现
        pass

class TcpServer(QTcpServer):
    def __init__(self, parent=None):
        super(TcpServer, self).__init__(parent)

    def incomingConnection(self, socketId):
        socket = Socket(self)
        socket.setSocketDescriptor(socketId)

class Socket(QTcpSocket):
    def __init__(self, parent=None):
        super(Socket, self).__init__(parent)
        self.connect(self, SIGNAL("readyRead()"), self.readRequest)
        self.connect(self, SIGNAL("disconnected()"), self.deleteLater)
        self.nextBlockSize = 0

    def readRequest(self):
        stream = QDataStream(self)
        stream.setVersion(QDataStream.Qt_4_2)
        if self.nextBlockSize == 0:
            if self.bytesAvailable() < SIZEOF_UINT16:
                return
            self.nextBlockSize = stream.readUInt16()
            if self.bytesAvailable() < self.nextBlockSize:
                return
        action = QString()
        room = QString()
        date = QDate()
        stream >> action
        if action in ("BOOK", "UNBOOK"):
            stream >> room >> date
            bookings = Bookings.get(date.toPyDate())
            uroom = unicode(room)
            if action == "BOOK":
                if bookings is None:
                    bookings = Bookings[date.toPyDate()]
                if len(bookings) < MAX_BOOKINGS_PER_DAY:
                    if uroom in bookings:
                        self.sendError("Cannot accept duplicate booking")
                    else:
                        bisect.insort(bookings, uroom)
                        self.sendReply(action, room, date)
                else:
                    self.sendError(QString("%1 is fully booked").arg(date.toString(Qt.ISODate)))
            elif action == "UNBOOK":
                if bookings is None or uroom not in bookings:
                    self.sendError("Cannot unbook nonexistent booking")
                else:
                    bookings.remove(uroom)
                    self.sendReply(action, room, date)
        else:
            self.sendError("Unrecognized request")

    def sendReply(self, action, room, date):
        reply = QByteArray()
        stream = QDataStream(reply, QIODevice.WriteOnly)
        stream.setVersion(QDataStream.Qt_4_2)
        stream.writeUInt16(0)
        stream << action << room << date
        stream.device().seek(0)
        stream.writeUInt16(reply.size() - SIZEOF_UINT16)
        self.write(reply)

    def sendError(self, msg):
        reply = QByteArray()
        stream = QDataStream(reply, QIODevice.WriteOnly)
        stream.setVersion(QDataStream.Qt_4_2)
        stream.writeUInt16(0)
        stream << QString("ERROR") << QString(msg)
        stream.device().seek(0)
        stream.writeUInt16(reply.size() - SIZEOF_UINT16)
        self.write(reply)
1.3 代码解释
  • BuildingServicesDlg 类 :继承自 QPushButton ,作为服务器的 GUI 部分。设置了窗口标志,使其始终置顶。创建了 TcpServer 实例,并让其监听所有网络接口的 9407 端口。如果启动失败,会弹出错误消息框并关闭窗口。
  • TcpServer 类 :继承自 QTcpServer ,当有新的连接请求时, incomingConnection 方法会被调用,创建一个新的 Socket 实例并设置其套接字描述符。
  • Socket 类 :继承自 QTcpSocket ,处理客户端的请求。
  • readRequest 方法:读取客户端的请求数据,根据请求动作( BOOK UNBOOK )进行相应的处理。如果是 BOOK 动作,会检查是否可以预订;如果是 UNBOOK 动作,会检查是否可以取消预订。如果请求动作不被识别,会发送错误消息。
  • sendReply 方法:向客户端发送成功回复。
  • sendError 方法:向客户端发送错误回复。
1.4 数据处理

服务器使用 collections.defaultdict 存储预订数据,键为日期( datetime.date 对象),值为房间号列表。当使用一个不存在的键时,会自动插入一个默认值(这里是空列表)。

1.5 服务器扩展

可以通过在 readRequest 方法中添加更多的 if 语句,来扩展服务器以处理更多的请求类型。例如,客户端可能想知道某一天哪些房间被预订,或者某个房间在哪些天被预订。

2. 服务器运行机制与注意事项
  • 事件循环 :该 TCP 服务器依赖于 PyQt 事件循环。如果要创建一个没有 GUI 的基于 QTcpServer 的服务器,有两种方法:
  • 使用 QEventLoop 提供事件循环,代码编写方式与有 GUI 的服务器类似。
  • 不使用事件循环,但需要使用阻塞的 QTcpServer.waitForNewConnection() 方法,而不是重写 incomingConnection() 方法。
  • 单线程问题 :该服务器是单线程的,当多个请求同时到达时,可能需要逐个处理,导致阻塞。可以使用多线程服务器来解决这个问题。
3. 服务器数据存储

虽然服务器使用字典来存储数据,但也可以将数据存储在 SQLite 数据库、外部数据库或文件中。而且服务器不一定需要 GUI,可以作为 Linux 守护进程或 Windows 服务在后台运行。

4. 多线程编程简介

在传统的应用程序中,通常只有一个执行线程,一次只能执行一个操作。对于 GUI 程序来说,这可能会导致问题,例如当用户调用一个长时间运行的操作时,用户界面可能会冻结。以下是几种解决这个问题的方法:
- 调用 QApplication.processEvents() :在长时间运行的循环中,调用此方法可以让事件循环有机会处理未处理的事件,如绘制事件、鼠标和键盘事件。
- 使用零超时定时器 :可以结合上述方法使用,例如在加载大量文件时使用。
- 将工作交给另一个程序 :可以使用 Python 标准库的 subprocess 模块或 PyQt 的 QProcess 类。

5. 多线程应用的优势与挑战
  • 优势
  • 可以创建一个能够处理尽可能多并发连接的服务器,为每个连接分配一个新线程。
  • 在 GUI 应用程序中,用户可以启动一个长时间运行的进程,然后继续与应用程序交互,将处理任务交给一个单独的次要线程,让主线程(GUI 线程)可以自由响应用户。
  • 挑战
  • 多线程应用程序通常比单线程应用程序更难编写、维护和调试,因为多个线程可能会同时访问相同的数据。
  • 在单处理器机器上,多线程应用程序有时可能比单线程应用程序运行得更慢,因为额外的线程会带来处理开销。但用户通常会感觉多线程应用程序运行得更快,因为它们不会冻结用户界面,并且更容易逐步向用户报告进度。
6. 线程数量对性能的影响

使用合适数量的线程可以显著影响性能。例如,在一个页面索引器示例中,有一个主线程(GUI 线程)和一个次要线程。如果使用过多的次要线程,应用程序可能会比只有一个次要线程的版本运行得更慢;但使用合适数量的次要线程,可以让多线程版本赶上、超过并先于单线程版本完成任务。线程数量的选择取决于具体的处理任务、机器和操作系统,可以通过实验来确定合适的数量,也可以根据情况动态调整。

7. PyQt 线程类的使用

Python 标准库提供了低级的 thread 模块和高级的 threading 模块,但对于 PyQt 编程,建议使用 PyQt 线程类。PyQt 线程类提供了高级 API,并且一些基本操作在底层使用汇编语言实现,以确保尽可能快和精细的操作,这是 Python 线程模块所没有的。

8. PyQt 应用程序的线程模型
  • 主线程 :PyQt 应用程序至少有一个执行线程,即主线程(初始线程)。如果应用程序有 GUI,GUI 操作(如执行事件循环)只能在主线程中进行。
  • 次要线程 :可以根据需要创建多个次要线程。新线程通过实例化 QThread 子类并重新实现 QThread.run() 方法来创建。
  • 线程间通信 :次要线程和主线程之间的通信通常是必要的,例如向用户报告进度、允许用户在处理过程中进行干预以及让主线程知道处理何时完成。传统上,这种通信通过使用共享变量和资源保护机制来实现。
9. 总结

Python 标准库、Twisted 网络引擎和 PyQt 的 QtNetwork 模块为网络编程提供了强大的支持,从低级套接字到各种高级协议(如 FTP 和 HTTP)。在编写客户端/服务器应用程序时,需要确保客户端和服务器程序能够通信,包括使用相同的协议(如 TCP)、相同的 IP 地址和端口号,以及约定好数据的传输格式。PyQt 的 QTcpServer QTcpSocket 类使得实现服务器变得非常容易,使用二进制数据可以更灵活地发送和接收任何类型的数据,而无需编写解析器。同时,多线程编程可以解决单线程服务器的阻塞问题,但也带来了一些挑战,需要合理选择线程数量以提高性能。

10. 练习
  • 修改服务器以支持 BOOKINGSONDATE 请求 :当收到此请求时,服务器应忽略房间号,检索给定日期的预订信息。如果没有预订,发送错误回复;否则,发送一个包含逗号分隔的房间号的字符串作为回复。
  • 修改客户端以支持 BOOKINGSONDATE 请求 :添加一个“Bookings on Date?”按钮,连接到一个方法,该方法发出合适的请求。修改客户端的 readResponse() 方法,以读取服务器对新请求的响应。
  • 修改服务器以支持 BOOKINGSFORROOM 请求 :当收到此请求时,服务器应忽略日期,遍历所有预订,累积给定房间被预订的日期列表。如果没有预订,返回错误回复;否则,发送自己的字节数组,包含长度、动作、房间字符串、日期列表的数量以及每个日期。
  • 修改客户端以支持 BOOKINGSFORROOM 请求 :添加一个“Bookings for Room?”按钮,连接到一个方法,该方法发出合适的请求。修改客户端的 readResponse() 方法,以读取服务器对新请求的响应,并创建一个合适的字符串在客户端用户界面中显示。

以下是服务器处理请求的流程图:

graph TD;
    A[收到请求] --> B{请求动作是否为 BOOK 或 UNBOOK};
    B -- 是 --> C[读取房间和日期];
    B -- 否 --> D[发送错误回复];
    C --> E{动作是 BOOK?};
    E -- 是 --> F{该日期是否有预订};
    F -- 否 --> G[为该日期创建空预订列表];
    F -- 是 --> H{该日期预订数量是否小于最大值};
    H -- 是 --> I{房间是否已预订};
    I -- 是 --> J[发送重复预订错误];
    I -- 否 --> K[插入预订并发送回复];
    H -- 否 --> L[发送日期已满错误];
    E -- 否 --> M{该日期是否有预订且房间已预订};
    M -- 是 --> N[取消预订并发送回复];
    M -- 否 --> O[发送不存在预订错误];

通过以上内容,我们详细介绍了如何创建一个 TCP 服务器以及多线程编程的相关知识和应用场景。希望这些内容能帮助你更好地理解网络编程和多线程编程的原理和实践。

创建 TCP 服务器与多线程编程指南

11. 详细实现练习需求
11.1 支持 BOOKINGSONDATE 请求
  • 服务器修改步骤
    1. Socket 类的 readRequest 方法中添加对 BOOKINGSONDATE 请求的处理逻辑。
    2. 当收到该请求时,忽略房间号,检索给定日期的预订信息。
    3. 如果没有预订,调用 sendError 方法发送错误回复。
    4. 若有预订,将预订的房间号用逗号分隔成字符串,调用 sendReply 方法发送回复。
class Socket(QTcpSocket):
    # ... 原有代码 ...

    def readRequest(self):
        # ... 原有代码 ...
        if action in ("BOOK", "UNBOOK", "BOOKINGSONDATE"):
            if action == "BOOKINGSONDATE":
                stream >> date
                bookings = Bookings.get(date.toPyDate())
                if not bookings:
                    self.sendError("No bookings on this date")
                else:
                    room_str = ", ".join(bookings)
                    self.sendReply(action, QString(room_str), date)
            elif action in ("BOOK", "UNBOOK"):
                # ... 原有代码 ...
  • 客户端修改步骤
    1. 在客户端界面添加一个“Bookings on Date?”按钮。
    2. 连接按钮的点击信号到一个方法,该方法发出 BOOKINGSONDATE 请求。
    3. 修改客户端的 readResponse() 方法,以读取服务器对新请求的响应。
# 客户端部分代码示例
class BuildingServicesClient(QWidget):
    def __init__(self, parent=None):
        # ... 原有代码 ...
        self.bookings_on_date_button = QPushButton("Bookings on Date?", self)
        self.bookings_on_date_button.clicked.connect(self.send_bookings_on_date_request)
        # ... 原有代码 ...

    def send_bookings_on_date_request(self):
        # 发送 BOOKINGSONDATE 请求
        date = QDate.currentDate()  # 示例日期
        request = QByteArray()
        stream = QDataStream(request, QIODevice.WriteOnly)
        stream.setVersion(QDataStream.Qt_4_2)
        stream.writeUInt16(0)
        stream << QString("BOOKINGSONDATE") << date
        stream.device().seek(0)
        stream.writeUInt16(request.size() - SIZEOF_UINT16)
        self.tcpSocket.write(request)

    def readResponse(self):
        # ... 原有代码 ...
        if action == "BOOKINGSONDATE":
            stream >> room >> date
            # 处理服务器返回的房间号字符串
            room_str = str(room)
            print(f"Bookings on {date.toString(Qt.ISODate())}: {room_str}")
        # ... 原有代码 ...
11.2 支持 BOOKINGSFORROOM 请求
  • 服务器修改步骤
    1. Socket 类的 readRequest 方法中添加对 BOOKINGSFORROOM 请求的处理逻辑。
    2. 当收到该请求时,忽略日期,遍历所有预订,累积给定房间被预订的日期列表。
    3. 如果没有预订,调用 sendError 方法返回错误回复。
    4. 若有预订,发送自己的字节数组,包含长度、动作、房间字符串、日期列表的数量以及每个日期。
class Socket(QTcpSocket):
    # ... 原有代码 ...

    def readRequest(self):
        # ... 原有代码 ...
        if action in ("BOOK", "UNBOOK", "BOOKINGSONDATE", "BOOKINGSFORROOM"):
            if action == "BOOKINGSFORROOM":
                stream >> room
                uroom = unicode(room)
                booked_dates = []
                for date, bookings in Bookings.items():
                    if uroom in bookings:
                        booked_dates.append(QDate(date.year, date.month, date.day))
                if not booked_dates:
                    self.sendError("No bookings for this room")
                else:
                    reply = QByteArray()
                    stream = QDataStream(reply, QIODevice.WriteOnly)
                    stream.setVersion(QDataStream.Qt_4_2)
                    stream.writeUInt16(0)
                    stream << action << room
                    stream.writeInt32(len(booked_dates))
                    for qdate in booked_dates:
                        stream << qdate
                    stream.device().seek(0)
                    stream.writeUInt16(reply.size() - SIZEOF_UINT16)
                    self.write(reply)
            elif action == "BOOKINGSONDATE":
                # ... 原有代码 ...
            elif action in ("BOOK", "UNBOOK"):
                # ... 原有代码 ...
  • 客户端修改步骤
    1. 在客户端界面添加一个“Bookings for Room?”按钮。
    2. 连接按钮的点击信号到一个方法,该方法发出 BOOKINGSFORROOM 请求。
    3. 修改客户端的 readResponse() 方法,以读取服务器对新请求的响应,并创建一个合适的字符串在客户端用户界面中显示。
class BuildingServicesClient(QWidget):
    def __init__(self, parent=None):
        # ... 原有代码 ...
        self.bookings_for_room_button = QPushButton("Bookings for Room?", self)
        self.bookings_for_room_button.clicked.connect(self.send_bookings_for_room_request)
        # ... 原有代码 ...

    def send_bookings_for_room_request(self):
        # 发送 BOOKINGSFORROOM 请求
        room = QString("Room 1")  # 示例房间号
        request = QByteArray()
        stream = QDataStream(request, QIODevice.WriteOnly)
        stream.setVersion(QDataStream.Qt_4_2)
        stream.writeUInt16(0)
        stream << QString("BOOKINGSFORROOM") << room
        stream.device().seek(0)
        stream.writeUInt16(request.size() - SIZEOF_UINT16)
        self.tcpSocket.write(request)

    def readResponse(self):
        # ... 原有代码 ...
        if action == "BOOKINGSFORROOM":
            stream >> room
            num_dates = stream.readInt32()
            booked_dates = []
            for _ in range(num_dates):
                qdate = QDate()
                stream >> qdate
                booked_dates.append(qdate.toString(Qt.ISODate()))
            date_str = ", ".join(booked_dates)
            print(f"Bookings for {str(room)}: {date_str}")
        # ... 原有代码 ...
12. 多线程编程的深入探讨
12.1 线程同步问题

在多线程编程中,多个线程可能会同时访问共享资源,如服务器中的 Bookings 字典。为了避免数据竞争和不一致的问题,需要进行线程同步。可以使用 Python 的 threading 模块中的锁机制,或者 PyQt 提供的同步类。

import threading

# 创建一个锁对象
bookings_lock = threading.Lock()

class Socket(QTcpSocket):
    # ... 原有代码 ...

    def readRequest(self):
        # ... 原有代码 ...
        if action in ("BOOK", "UNBOOK"):
            stream >> room >> date
            with bookings_lock:
                bookings = Bookings.get(date.toPyDate())
                uroom = unicode(room)
                if action == "BOOK":
                    if bookings is None:
                        bookings = Bookings[date.toPyDate()]
                    if len(bookings) < MAX_BOOKINGS_PER_DAY:
                        if uroom in bookings:
                            self.sendError("Cannot accept duplicate booking")
                        else:
                            bisect.insort(bookings, uroom)
                            self.sendReply(action, room, date)
                    else:
                        self.sendError(QString("%1 is fully booked").arg(date.toString(Qt.ISODate)))
                elif action == "UNBOOK":
                    if bookings is None or uroom not in bookings:
                        self.sendError("Cannot unbook nonexistent booking")
                    else:
                        bookings.remove(uroom)
                        self.sendReply(action, room, date)
        # ... 原有代码 ...
12.2 创建和管理线程

在 PyQt 中,可以通过继承 QThread 类来创建自定义线程。以下是一个简单的示例:

from PyQt4.QtCore import QThread, pyqtSignal

class MyThread(QThread):
    finished = pyqtSignal()

    def __init__(self, parent=None):
        super(MyThread, self).__init__(parent)

    def run(self):
        # 线程执行的任务
        for i in range(10):
            print(f"Thread is running: {i}")
        self.finished.emit()

# 在主程序中使用线程
if __name__ == "__main__":
    app = QApplication([])
    thread = MyThread()
    thread.finished.connect(app.quit)
    thread.start()
    app.exec_()
13. 总结与展望

通过以上内容,我们全面介绍了如何创建一个 TCP 服务器,包括服务器的组件、代码实现、数据处理和扩展方法。同时,深入探讨了多线程编程的相关知识,如多线程的优势与挑战、线程数量对性能的影响、线程同步问题以及线程的创建和管理。

在实际应用中,我们可以根据具体需求进一步优化服务器和客户端的代码。例如,使用更高效的数据结构来存储预订信息,优化线程同步机制以提高性能,或者添加更多的请求类型以满足不同的业务需求。

以下是服务器和客户端交互的流程图:

graph LR;
    A[客户端] --> B[发送请求];
    B --> C[服务器接收请求];
    C --> D{请求类型};
    D -- BOOK --> E[处理预订请求];
    D -- UNBOOK --> F[处理取消预订请求];
    D -- BOOKINGSONDATE --> G[处理查询某天预订请求];
    D -- BOOKINGSFORROOM --> H[处理查询某房间预订请求];
    E --> I[服务器发送回复];
    F --> I;
    G --> I;
    H --> I;
    I --> J[客户端接收回复];

希望这些内容能帮助你在网络编程和多线程编程领域取得更好的成果,不断探索和实践,创造出更高效、稳定的应用程序。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值