简介:这份资源是广开(国开)电大网络编程技术实践技能训练1的参考答案,面向正在学习Web前端基础、需要完成购物车页面实训任务的电大学员与自学者。压缩包共5个文件,包含html页面结构、css样式表、js交互脚本以及两张jpg图片素材,整体约62KB,体量轻便,便于快速查阅与对照练习。资源围绕HTML布局、CSS美化与JavaScript交互三条主线展开,涵盖商品列表、数量输入、加入购物车按钮、总价计算、输入合法性校验及localStorage数据持久化等典型知识点,并涉及AJAX异步更新思路,可帮助读者理解前端数据存储与异步通信的基本用法。目前已有253人学习,适合作为实训提交前的参考模板与排错对照,也可用于梳理购物车页面的完整实现逻辑。
1. 网络编程技术实践技能训练1:从套接字到并发服务的完整落地路径
很多同学拿到“网络编程技术实践技能训练1”这个任务时,第一反应是去搜答案,但真正做过一轮的人都知道,这个训练的核心不是背概念,而是把 TCP 客户端-服务端通信、多线程并发处理、异常断开重连这三件事串起来跑通。它解决的是“看得懂 socket 函数但写不出稳定服务”的典型断层,适合已经学过 C 或 Python 基础、但没独立写过网络程序的人。我当年第一次做的时候,服务端 accept 之后直接阻塞在 recv,客户端一断整个进程就挂,血泪经验告诉我:训练1真正要练的是对阻塞、并发和连接生命周期的控制感,而不是抄一份能跑的代码。
2. 训练1到底在练什么:TCP 通信的最小闭环与三个必调参数
2.1 从 socket 到 accept:一次完整 TCP 连接经历了什么
训练1通常要求实现一个基本的客户端-服务端通信程序,服务端监听指定端口,客户端连接后发送数据,服务端接收并回显。这个流程看起来简单,但每一步都有容易翻车的地方。
先看服务端的最小骨架。以 Python 为例,核心代码不超过 15 行,但参数选错就会出玄学问题:
import socket # 创建 TCP socket server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 设置端口复用,避免重启时 Address already in use server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # 绑定地址和端口,'0.0.0.0' 表示监听所有网卡 server.bind(('0.0.0.0', 8888)) # 开始监听,backlog 设为 5 server.listen(5) print('服务端启动,监听 8888 端口') while True: # 阻塞等待客户端连接 conn, addr = server.accept() print(f'新连接来自 {addr}') # 接收数据,缓冲区 1024 字节 data = conn.recv(1024) if data: print(f'收到: {data.decode()}') # 回显数据 conn.sendall(data) # 关闭本次连接 conn.close()这段代码的逻辑说明:AF_INET指定 IPv4 地址族,SOCK_STREAM指定 TCP 协议。SO_REUSEADDR是关键参数,不设置的话,服务端程序退出后端口会处于 TIME_WAIT 状态,短时间内无法重新绑定,调试时反复重启就会遇到OSError: [Errno 98] Address already in use。listen(5)中的 5 是 backlog,表示内核为该 socket 维护的已完成连接队列长度,高并发场景下这个值需要调大。
客户端对应代码:
import socket client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 连接服务端,注意这里用 127.0.0.1 而非 0.0.0.0 client.connect(('127.0.0.1', 8888)) client.sendall('hello'.encode()) # 接收回显,同样 1024 缓冲区 resp = client.recv(1024) print(f'服务端回显: {resp.decode()}') client.close()这里有个新手常踩的坑:客户端连接时目标地址写0.0.0.0会直接报错,0.0.0.0只能用于 bind 表示监听所有网卡,不能用于 connect。
2.2 backlog、缓冲区大小、超时时间:三个参数决定程序能不能用
训练1的评分往往不只看能不能跑通,还看程序在异常情况下的表现。以下三个参数直接决定程序的健壮性:
| 参数 | 位置 | 典型值 | 作用 | 调错后果 |
|---|---|---|---|---|
| backlog | listen() | 5~128 | 已完成连接队列长度 | 太小导致并发连接被拒 |
| 缓冲区大小 | recv()/send() | 1024~65536 | 单次读写最大字节数 | 太小导致大数据分片,太大浪费内存 |
| 超时时间 | settimeout() | 5~30 秒 | 阻塞操作最长等待时间 | 不设置则永久阻塞,设置过短误判断开 |
settimeout的用法值得单独说。默认情况下,recv和accept都是永久阻塞的,如果客户端连上不发数据,服务端就卡死在recv上,整个程序失去响应。加上超时后:
conn.settimeout(10) # 10 秒内没有数据就抛 socket.timeout try: data = conn.recv(1024) except socket.timeout: print('接收超时,关闭连接') conn.close()注意,超时后连接不一定已经断开,只是本次 recv 没有拿到数据,需要根据业务决定是重试还是关闭。我一般会在训练1里把超时设为 10 秒,既能演示异常处理,又不会让调试等太久。
2.3 多线程并发:让服务端同时处理多个客户端
单线程服务端的致命问题是:处理第一个连接时,第二个连接只能排队等 accept。训练1通常要求支持至少两个客户端同时通信,这就必须引入并发。常见做法是每个连接开一个线程:
import socket import threading def handle_client(conn, addr): print(f'处理来自 {addr} 的连接') conn.settimeout(10) try: while True: data = conn.recv(1024) if not data: break # 客户端正常关闭 conn.sendall(data) except socket.timeout: print(f'{addr} 接收超时') finally: conn.close() print(f'{addr} 连接关闭') server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind(('0.0.0.0', 8888)) server.listen(10) while True: conn, addr = server.accept() # 每个连接启动一个守护线程 t = threading.Thread(target=handle_client, args=(conn, addr), daemon=True) t.start()逻辑说明:daemon=True表示主线程退出时子线程自动结束,避免程序无法退出。handle_client里用while True循环持续接收,直到recv返回空字节串,这表示客户端调用了close()或shutdown()。注意if not data判断的是空 bytes,不是 None,这是 TCP 连接正常关闭的标准信号。
参数方面,listen(10)的 backlog 调到了 10,因为现在有并发需求。线程数没有硬上限,但训练1的场景下,超过 50 个并发连接就应该考虑线程池或 IO 多路复用,否则线程切换开销会拖慢响应。
3. 从能跑到能过:训练1的完整实现步骤与调试方法
3.1 环境准备与分步实现顺序
训练1一般不限语言,C、Python、Java 都可以。我建议用 Python,因为 socket API 封装层次适中,能看清底层行为又不用处理内存管理。环境只需要标准库,不需要额外安装包。
实现顺序建议按以下步骤走,每步验证后再进入下一步:
- 写一个单线程服务端,accept 后打印客户端地址,不接收数据,用
telnet或客户端脚本测试连接是否建立。 - 加入 recv 和 sendall,实现回显,用客户端发一条消息验证。
- 加入 settimeout,测试客户端连上不发数据时服务端是否能在超时后释放连接。
- 加入多线程,同时启动两个客户端,验证两个连接互不阻塞。
- 加入异常处理,测试客户端强制断开(直接杀进程)时服务端是否报错。
每一步的验证命令:
# 测试端口是否监听 netstat -an | grep 8888 # 用 telnet 手动测试(部分系统需先安装 telnet 客户端) telnet 127.0.0.1 88883.2 用日志定位阻塞和异常断开
训练1最常见的调试需求是:程序卡住了,不知道卡在哪。我的习惯是在关键路径上加日志:
import logging logging.basicConfig(level=logging.DEBUG, format='%(asctime)s %(levelname)s %(message)s') # 在 accept 前 logging.debug('等待新连接...') conn, addr = server.accept() logging.debug(f'接受连接: {addr}') # 在 recv 前 logging.debug(f'等待 {addr} 数据...') data = conn.recv(1024) logging.debug(f'收到 {len(data)} 字节')日志能直接告诉你卡在 accept 还是 recv。如果卡在 accept,说明没有客户端连上来,检查客户端目标 IP 和端口。如果卡在 recv,说明连接已建立但客户端没发数据,检查客户端是否执行了 send。
异常断开的表现是ConnectionResetError或BrokenPipeError。客户端直接杀进程时,服务端 recv 会收到ConnectionResetError: [Errno 104] Connection reset by peer。这不是 bug,是 TCP 的正常行为,需要用 try-except 捕获:
try: data = conn.recv(1024) except ConnectionResetError: logging.warning(f'{addr} 强制断开') conn.close() return3.3 验证并发是否真正生效
多线程写完后,怎么确认两个客户端是并发处理的而不是排队的?方法很简单:客户端 A 连上后不发数据,客户端 B 连上后发数据。如果 B 能立刻收到回显,说明并发生效;如果 B 卡住直到 A 超时,说明还是串行。
测试脚本:
# client_a.py:连上后 sleep 15 秒不发数据 import socket, time c = socket.socket(socket.AF_INET, socket.SOCK_STREAM) c.connect(('127.0.0.1', 8888)) print('A 已连接,等待 15 秒') time.sleep(15) c.close()# client_b.py:连上后立即发数据 import socket c = socket.socket(socket.AF_INET, socket.SOCK_STREAM) c.connect(('127.0.0.1', 8888)) c.sendall('from B'.encode()) print(c.recv(1024).decode()) c.close()先运行 A,再运行 B。如果 B 在 1 秒内打印出from B,并发正确。如果 B 等到 A 的 15 秒结束才打印,说明 accept 或 recv 被串行化了,检查是否忘了开线程。
4. 训练1避坑指南:五个让程序翻车的典型问题
4.1 端口被占用导致服务端启动失败
现象:运行服务端时报OSError: [Errno 98] Address already in use。
原因:上一次运行的服务端进程没有完全退出,或者虽然退出了但端口处于 TIME_WAIT 状态。TCP 规定主动关闭方需要等待 2MSL(通常 60 秒)才能释放端口。
解决:设置SO_REUSEADDR为 1,允许绑定处于 TIME_WAIT 的端口。如果已经设置了还报错,用lsof -i :8888或netstat -tlnp | grep 8888找到残留进程并 kill 掉。
4.2 recv 返回空字节串被误判为错误
现象:客户端正常关闭后,服务端 recv 返回b'',程序把它当成空数据继续循环,导致死循环。
原因:TCP 中recv返回空 bytes 表示对端已关闭连接,这是正常关闭信号,不是错误。
解决:在循环中判断if not data: break,收到空字节串就跳出循环并关闭连接。注意区分b''和None,recv永远不会返回 None。
4.3 多线程共享变量导致数据错乱
现象:多个客户端同时连接时,服务端打印的地址或数据出现串台。
原因:多个线程共享了同一个全局变量(比如用一个全局 list 存客户端信息),没有加锁。
解决:训练1的场景下,每个线程只操作自己的 conn 和 addr,不共享可变状态。如果确实需要共享,用threading.Lock()保护临界区。我一般建议训练1不要引入共享状态,保持每个连接独立处理。
4.4 客户端 connect 被拒绝
现象:客户端报ConnectionRefusedError: [Errno 111] Connection refused。
原因:服务端没有启动,或者服务端 bind 的地址和客户端 connect 的地址不匹配。常见错误是服务端 bind 了127.0.0.1,客户端却连192.168.x.x。
解决:服务端 bind0.0.0.0表示监听所有网卡,客户端连本机用127.0.0.1,连局域网其他机器用服务端的实际 IP。先用ping确认网络可达,再用telnet确认端口开放。
4.5 发送大数据时 recv 只收到一部分
现象:客户端 sendall 发送 10000 字节,服务端一次 recv 只收到 4096 字节。
原因:TCP 是字节流协议,不保证 send 和 recv 的次数一一对应。内核发送缓冲区和接收缓冲区大小有限,大数据会被分片。
解决:在应用层定义消息边界。简单做法是发送方先发 4 字节的长度头,接收方先读 4 字节得到长度,再循环读取直到收满。训练1如果只传短消息,可以把缓冲区设为 65536 减少分片概率,但正式场景必须做消息 framing。
5. 进阶技巧:用 select 替代多线程做 IO 多路复用
训练1做完多线程版本后,如果还想深入,下一步就是用select或selectors模块实现单线程并发。这不是训练1的硬性要求,但能让你真正理解“并发不一定靠线程”。
select的核心思路是:把所有需要监听的 socket 放入一个列表,调用select阻塞等待,只要有任何一个 socket 可读或可写就返回,然后逐个处理。这样单线程就能管理多个连接,没有线程切换开销。
import socket import select server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind(('0.0.0.0', 8888)) server.listen(10) server.setblocking(False) # 设为非阻塞 inputs = [server] # 监听列表 while True: # 阻塞等待,直到有 socket 可读 readable, _, _ = select.select(inputs, [], []) for sock in readable: if sock is server: # 新连接 conn, addr = sock.accept() conn.setblocking(False) inputs.append(conn) print(f'新连接: {addr}') else: # 已有连接有数据 try: data = sock.recv(1024) if data: sock.sendall(data) else: # 客户端关闭 inputs.remove(sock) sock.close() except ConnectionResetError: inputs.remove(sock) sock.close()逻辑说明:select.select(inputs, [], [])的第一个参数是读监听列表,第二个是写监听列表,第三个是异常监听列表。返回的 readable 列表中包含所有可读的 socket。setblocking(False)是关键,非阻塞模式下 accept 和 recv 不会卡住,没有数据时立刻返回异常,所以必须配合 select 使用。
参数方面,select在 Linux 下默认最多监听 1024 个文件描述符,训练1的场景完全够用。如果连接数超过这个量级,应该用epoll(Linux)或kqueue(macOS),Python 的selectors模块会自动选择最优实现。
我自己的习惯是:训练1先用多线程跑通,理解连接生命周期;再用 select 重写一遍,理解事件驱动。两版都跑过之后,后面遇到高并发场景就不会慌。这个训练的价值不在于答案本身,而在于你亲手处理过连接断开、超时、分片这些真实问题。希望帮到你。
本文还有配套的精品资源,点击获取