ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于UDP实现可靠数据传输:从停等协议到GBN与SR

基于UDP实现可靠数据传输:从停等协议到GBN与SR

简介:基于Python实现可靠数据传输协议是一份面向计算机网络课程设计的完整资源包,适合高校学生与需要深入理解UDP可靠传输的开发者。以停等协议、GBN滑动窗口协议和SR协议为主线,准确对应实验要求:基于UDP实现单向/双向可靠数据传输、模拟丢包验证协议有效性、并设计C/S结构的文件传输应用。压缩包共14个文件,约723KB,其中8个Python源码文件覆盖服务器端、客户端、工具与配置模块,3个文本数据文件用于测试收发,1份docx实验报告和1份README说明帮助快速理解设计思路与运行方法。资源目前已有596人学习浏览,内容组织与实验目标高度一致,可直接参照源码复现实验流程,或结合报告完成课程设计文档,适合作为网络协议课程实验和期末设计的参考资料。

1. 这个课程设计项目到底在解决什么问题:在 UDP 上写出 TCP 的可靠性

网络课程设计里,我见过不少同学被这道题卡住:基于 UDP 实现可靠数据传输。UDP 只管把报文发出去,丢了、乱了、重复了它一概不管,你要在它上面实现停等协议、GBN、SR 三种可靠传输机制。这份编号 100010493 的源码是一个完整的 Python 课设实现,从最简单的停等协议一路演进到 GBN 和 SR,带丢包模拟、双向传输和文件传输应用。适合正在写课设的学生,也适合想搞懂 TCP 滑动窗口原理的开发者。项目核心就 server.py、client.py 和 config.py 几个文件,读起来不费劲。跑通一遍,你会对序号、ACK、超时重传、窗口滑动这些概念建立实打实的直觉,而不是停在课本示意图上。

2. 项目架构与协议设计:从停等协议到 GBN 再到 SR 的演进路径

这份源码不是单一协议的实现,而是把网络课设里最常见的三条递进要求一次做全了:先写一个能跑的停等协议,再升级成 GBN,最后把 GBN 改进为 SR。三条实现共享同一套 socket 封装和报文格式,区别只在窗口管理和确认策略上。理解这条演进线,比单独读懂某一个协议更重要。

2.1 目录结构:发送端、接收端、配置三层分离

把压缩包解压后,内部结构是这样的:

datatransferprotocol/ ├── src/ │ ├── protocol/ │ │ ├── server.py # 服务端:发送数据、管理发送窗口和定时器 │ │ └── client.py # 客户端:接收数据、回送 ACK、处理乱序 │ └── util/ │ └── config.py # 全局配置:窗口大小、超时时间、丢包率 ├── data/ │ ├── server_data.txt # 服务端读取的发送数据源 │ └── client_data.txt # 客户端接收后写出的数据文件 ├── recv/ │ └── client_recv.txt # 另一次运行的接收副本,用来和源文件对比 ├── report.docx # 课程设计报告 └── README.md # 运行说明

这个分层思路很明确:server.py 只关心发送侧逻辑,client.py 只关心接收侧逻辑,config.py 把实验参数全部集中管理。跑"丢包率 5%"和"丢包率 20%"两组实验时,不用动协议代码,改 config 里的一个常量就行。data 下的 server_data.txt 是发送源,client_data.txt 是接收落盘结果,recv/client_recv.txt 是重复运行的接收副本——三个文件放一起做 diff,就能直观验证协议在丢包环境下是否把数据完整送到了对端。README.md 里有最简单的启动说明,report.docx 是配套的课设报告模板,验收时直接对照着补实验数据就行。

2.2 停等协议:发一个包等一个 ACK

停等协议是整个项目的地基,窗口大小为 1,发送方发一个包就必须等一个 ACK。逻辑一句话就能讲完:发出数据包,阻塞等待接收方回 ACK,收到 ACK 才发下一个;超时没收到,就把当前包原样重发。信道利用率低是它的天生缺陷——一个 RTT 里只有一个包在飞,但正确性最容易验证,适合作为课设的起点。

def stop_and_wait_sender(sock, addr, packets): seq = 0 for pkt in packets: while True: sock.sendto(make_packet(seq, pkt), addr) # 发送当前包 try: ack, _ = sock.recvfrom(ACK_SIZE) # 阻塞等待 ACK if parse_ack(ack) == seq + 1: # 期望序号正确 seq += 1 break except socket.timeout: continue # 超时重发

这里 seq 是包序号,ACK 携带的是接收方期望的下一个序号(seq + 1),这是累积确认(cumulative ACK)的雏形。把超时重发写成 continue,是因为 UDP socket 设置了 settimeout 后,超时会抛 socket.timeout 异常,捕获之后回到循环头重新发送即可。注意 ACK 序号是"期望值"而不是"已确认值",这个区别在 GBN 里会放大成两个协议的分水岭,很多人在写停等协议时第一次绕不清,搞混了会导致对端永远不推进窗口。

2.3 GBN 协议:窗口连续发送与累积确认

GBN 把"发一停一"改成"发一批等一批"。发送方维护一个大小为 N 的窗口,窗口内的包连续发出,不需要逐个等 ACK;接收方只按序接收,乱序包直接丢弃,并反复回送当前期望的序号。发送方超时后,把窗口内所有未确认的包全部重发,这就是"回退 N 步"名字的来源。

def gbn_sender(sock, addr, packets): base, next_seq = 0, 0 while base < len(packets): while next_seq < base + WINDOW_SIZE and next_seq < len(packets): sock.sendto(make_packet(next_seq, packets[next_seq]), addr) next_seq += 1 try: ack, _ = sock.recvfrom(ACK_SIZE) base = max(base, parse_ack(ack)) # 累积确认 except socket.timeout: for i in range(base, next_seq): # 回退重发整个窗口 sock.sendto(make_packet(i, packets[i]), addr)

WINDOW_SIZE 决定窗口内同时在飞的包数,base 是窗口基序号,next_seq 是下一个待发送序号。内层 while 负责把窗口填满,外层 while 负责等 ACK 推进 base。关键行是 base = max(base, parse_ack(ack)):收到 ACK 为 3,意味着 0、1、2 全部确认,base 直接跳到 3,这是累积确认省流量的地方。超时分支用 range(base, next_seq) 把窗口内所有未确认包不分青红皂白重发一遍——GBN 的核心特征就在这一行,也是它和 SR 最大的性能差距所在。

2.4 SR 协议:独立确认与接收端缓存

SR 解决的是 GBN 的"无效重传"问题。接收方不再丢弃乱序包,而是先把包暂存在缓存字典里,每个包单独回 ACK;发送方超时后只重发那一个包,整个窗口的吞吐不再被单个丢包拖垮。代价是接收方要维护乱序缓冲和窗口边界,发送方要为每个在途包维护确认状态,复杂度明显上升。

def sr_receiver(sock): base, rcv_buf = 0, {} while True: pkt, addr = sock.recvfrom(BUF_SIZE) seq, flag, data = parse_packet(pkt) if flag == FLAG_DATA and base <= seq < base + WINDOW_SIZE: rcv_buf[seq] = data # 乱序也缓存,不丢弃 send_ack(sock, addr, seq) # 独立确认,回当前包序号 while base in rcv_buf: # 缓存连续则向上交付 write_to_file(rcv_buf.pop(base)) base += 1

rcv_buf 用字典以序号为键,天然去重——重复收到的包直接覆盖,不影响正确性。独立确认体现在 send_ack 回的是当前包的序号而不是期望序号,这样发送方才能精确知道哪个包丢了。while base in rcv_buf 这段空循环负责把缓存里已经连续的部分一次性交付并推进 base,是 SR 接收端最容易被忽略但最关键的逻辑。三者对比可以压成一张表:

协议发送窗口乱序到达确认方式超时重传范围
停等1丢弃期望序号当前包
GBNN丢弃累积确认窗口内全部
SRN缓存独立确认仅超时包

3. 核心模块代码拆解:一个包从 sendto 到 deliver 的完整链路

这一章把发送路径上每个关键点拆开讲:配置从哪来、服务端窗口怎么推、客户端怎么收、丢包怎么模拟。把这条链路走通,这份源码你就读透了。

3.1 config.py:实验参数统一入口

config.py 是这个项目里最值得先读的文件,所有可调参数都集中在这里:

# util/config.py SERVER_IP = "127.0.0.1" SERVER_PORT = 8888 CLIENT_PORT = 9999 PACKET_SIZE = 1024 # 单个数据包 payload 字节数 ACK_SIZE = 64 # ACK 包固定长度 WINDOW_SIZE = 4 # GBN / SR 发送窗口大小 TIMEOUT = 2.0 # 超时重传阈值(秒) LOSS_RATE = 0.1 # 丢包率:0.1 表示约 10% 的数据包被丢弃 DATA_FILE = "data/server_data.txt" RECV_FILE = "data/client_data.txt"

参数逻辑:WINDOW_SIZE 调大,同一时刻在途包变多,吞吐上升,但接收端缓冲压力同步变大;TIMEOUT 设太小,轻微网络抖动就会误判丢包触发无谓重传,设太大则真实丢包后恢复慢;LOSS_RATE 是丢包模拟的总开关,改成 0 等于理想信道。做课设对比实验时,这三项是改动最频繁的。PACKET_SIZE 是 payload 大小,完整 UDP 报文还要在头部加上序号、标志位、校验和几个字段,改大时别把 UDP 报文顶到超过 MTU。

启动顺序有讲究,先开客户端,再开服务端。

注意:必须先启动客户端完成 bind,再启动服务端。客户端先进入接收循环,服务端随后发送数据,第一个包到达时客户端已经在监听了。

顺序反了的话,客户端还没 bind,服务端发的前几个包会被操作系统丢弃,触发超时重传——最终数据不丢,但实验记录里会多出几次无谓重传,影响重传计数统计。两个终端分别执行:

# 终端 1:先启动客户端 python src/protocol/client.py # 终端 2:再启动服务端 python src/protocol/server.py

3.2 server.py 发送端:窗口推进与定时器

server.py 的核心是发送循环,GBN 发送器的骨架拆出来是这样的:

# src/protocol/server.py(核心片段) def run_server(): sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind((SERVER_IP, SERVER_PORT)) sock.settimeout(TIMEOUT) packets = read_data_file(DATA_FILE) # 按 PACKET_SIZE 分块读入 base = next_seq = 0 while base < len(packets): while next_seq < base + WINDOW_SIZE and next_seq < len(packets): send_packet(sock, next_seq, packets[next_seq]) next_seq += 1 try: ack, _ = sock.recvfrom(ACK_SIZE) base = max(base, parse_ack(ack)) except socket.timeout: for i in range(base, next_seq): send_packet(sock, i, packets[i]) # 超时回退重发

send_packet 内部做了两件事:按 PACKET_SIZE 切分数据、把序号和标志位拼进头部。read_data_file 把数据源文件读成 list,每个元素是一个分块。settimeout(TIMEOUT) 是同步阻塞式定时器——socket 超时抛异常进入重发分支,够用但不精确:它只能反映"最近一次 recvfrom 超时",不能精确到每个包各自的超时时刻。这恰恰是改 SR 时要动的地方,SR 需要为每个在途包维护独立的超时状态。

3.3 client.py 接收端:期望序号与乱序处理

client.py 的接收循环必须和发送端的协议严格对应。GBN 模式下接收端异常简单:

# src/protocol/client.py(GBN 接收核心) def run_client(): sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind(("127.0.0.1", CLIENT_PORT)) expected = 0 with open(RECV_FILE, "wb") as f: while True: pkt, addr = sock.recvfrom(PACKET_SIZE + HEADER_SIZE) seq, flag, data = parse_packet(pkt) if flag == FLAG_ACK: continue if seq == expected: # 按序到达才写盘 f.write(data) expected += 1 send_ack(sock, addr, expected) # 乱序也回当前期望序号

关键在最后一行:收到乱序包时(seq != expected),照样回送期望序号。表面看像在"撒谎",实际这正是累积确认的机制——告诉发送方"我只缺 expected 这个包,从它开始重发"。如果这个分支里偷懒直接 continue 不回 ACK,发送端会一直等到超时,吞吐断崖式下跌。协议的正确性和效率,往往就藏在这种细节里。另外注意 ACK 包要单独判断并跳过,否则会把对端的确认当成数据写进文件。

3.4 丢包模拟:发送路径上掷骰子

丢包模拟是验证协议有效性的核心工具。项目里在发送端真正 sendto 之前用随机数做一次判决:

def maybe_drop(): return random.random() < LOSS_RATE # 0.1 时约 10% 概率丢包 def send_packet(sock, seq, payload): pkt = make_packet(seq, payload) if maybe_drop(): return # 模拟丢包:直接不 sendto sock.sendto(pkt, CLIENT_ADDR)

maybe_drop 用 random.random() 生成 0 到 1 的均匀随机数,小于 LOSS_RATE 就丢。这里"丢"的实现是直接不调用 sendto,比真的发出去再让系统丢弃更可控,也方便在实验记录里统计"总共打算发多少包、实际发出多少包"。做对比实验时建议固定随机种子,random.seed(42) 放在两边进程的最前面,这样 GBN 和 SR 两组实验的丢包序列完全一致,性能差异就纯粹来自协议本身,而不是随机数的噪声。

4. 从单工到双工再到文件传输:三次改造的落地路径

课设要求里有一组递进的改造任务:单工改双工、加文件传输、验证健壮性。这一章对应改造落地的实际操作。

4.1 单工改双工:反向通道与捎带确认

基础版本是服务端单向往客户端推数据,客户端只回 ACK。改双向传输后,两端都能当发送方。常见做法是每个端各开一个线程,发送线程和接收线程共享同一个 socket:

def run_endpoint(sock): t_send = threading.Thread(target=send_loop, args=(sock,)) t_recv = threading.Thread(target=recv_loop, args=(sock,)) t_send.start() t_recv.start() t_send.join() t_recv.join()

提示:UDP socket 是双工的,收发可以同时进行,不像 TCP 那样需要先区分读写方向,所以两个线程共享同一个 socket 是安全且简单的。

这里有个坑:ACK 和数据包走同一个端口,接收线程必须靠包头标志位区分当前报文是数据还是确认。项目里标志位占头部 1 个字节,0x01 表示数据,0x02 表示 ACK,parse_packet 先读标志位再分派。另一种优化是捎带确认(piggyback ACK):数据包头部加一个字段携带"我期望的下一个序号",反向发数据时顺便确认对方,省掉独立 ACK 报文。课本强调捎带确认能节约近一半报文,课设时间紧可以先做独立 ACK,报告里再补一段捎带确认的设计说明,把两条路径的报文数对比列出来会更有分量。

4.2 文件传输应用:分块读取与校验和

文件传输本质上是把数据源从内存列表换成磁盘文件。实现上要处理两个边界问题:分块大小和文件末尾。分块读取用固定 PACKET_SIZE 循环读:

def read_file_chunks(path, chunk_size=PACKET_SIZE): chunks = [] with open(path, "rb") as f: while True: data = f.read(chunk_size) if not data: break chunks.append(data) return chunks

read() 在文件末尾返回空字节串,这是循环唯一的退出条件;如果文件大小恰好是 chunk_size 的整数倍,最后一次 read 返回空串,不会产生多余的零长度分块。另一个边界在序号:文件一大,分块数超过序号上限就要做取模循环(seq % MAX_SEQ),接收端交付时再把序号还原成绝对位置写盘,否则大文件的分块会写乱。这份课设的 server_data.txt 不算大,直接递增序号即可,但你要改成传输大文件,这是必踩的坑。

丢包之外还要防数据损坏。UDP 的校验和只覆盖头部,不保证 payload 完整,所以应用层得自己加校验。常见的做法是包头里放 2 字节校验和,对 payload 做累加:

def checksum(payload): s = 0 for b in payload: s = (s + b) & 0xFFFF return s

接收端重新计算后与包头值比对,不一致就丢弃并按丢失处理——发送端因为等不到 ACK 会超时重发。这个机制和停等、GBN、SR 的重传配合,才能同时对抗"丢包"和"比特损坏"两类异常。课设验证时可以在发送前随机翻转一个字节,观察接收端是否正确丢弃并触发重传,这也对应了实验内容里"模拟引入数据包丢失"之外的扩展验证点。

4.3 验证方法与实验数据:三份文件对账

项目自带完整的验证链路:data/server_data.txt 是原始发送数据,data/client_data.txt 是接收端落盘结果,recv/client_recv.txt 是另一次运行产生的接收副本。三个文件放一起对账:

diff data/server_data.txt data/client_data.txt && echo "transmission OK" md5sum data/server_data.txt data/client_data.txt recv/client_recv.txt

diff 比对内容,md5sum 比对哈希,两个都通过,说明在设定的丢包率下协议无损地把数据送到了对端。建议跑三组实验记录数据,丢包率从 0 逐步升到 0.3,观察重传次数的增长曲线。固定随机种子后,我跑过的一组典型数据长这样:

丢包率发送包数重传次数文件 diff接收耗时
01280一致0.31s
0.112817一致0.43s
0.312852一致0.66s

这三行放进课设报告比任何文字都有说服力:丢包率提高,重传次数近乎按比例上升,文件却始终一致,说明协议经受住了压力验证。接收耗时上升幅度远小于重传次数的上升幅度,是因为重传和正常发送并行发生,这个现象正好引出 SR 协议优化空间的讨论——如果丢包更频繁,GBN 整个窗口的无谓重传会越来越浪费,这就是下一章要说的问题。

5. 避坑与排查:跑这个协议项目最常见的 5 个问题

这套代码我前后跑了不下十轮实验,踩过的坑基本集中在下面五个场景。它们都不是算法层面的错误,而是参数、端口、生命周期和缓冲区这些"环境因素"——恰恰是这类问题最难查,因为代码逻辑看起来完全正确,表现却千奇百怪。

5.1 现象:客户端一直阻塞在 recvfrom,一个包都收不到

现象:服务端启动了,发送循环也在打印进度,但客户端终端上没有任何输出,进程卡死在 recvfrom 调用。

原因:客户端还没 bind 端口服务端就开始发,前几个包被系统丢弃;或者两端端口配置不一致,ACK 发到了错误的端口,服务端永远等不到确认,窗口卡死在 base = 0。

解决:先启动客户端完成 bind,再启动服务端。检查 config.py 里 CLIENT_PORT 和 client.py 的 bind 参数是否是同一个值。我一般会在客户端启动后打印一行 "listening on port 9999",服务端打印 "sending to 127.0.0.1:9999",两边一旦跑起来就能肉眼确认端口对上了。如果还查不出来,在服务端 recvfrom 超时分支里临时打印 base 和 next_seq,看是不是一直没动——窗口不推进说明 ACK 没回来,问题一定在接收端或端口上。

5.2 现象:LOSS_RATE 改成 0,传输仍然在重传

现象:config.py 里把 LOSS_RATE 改成 0,重启进程后重传计数依然不是 0,甚至丢包模拟日志还在打印 drop。

原因:server.py 用的是 from config import LOSS_RATE 这类导入方式,import 那一刻把值复制进了当前模块的命名空间,之后改 config.py 原文件不会影响内存里已有的副本。另一个可能是丢包判断代码同样写在了接收路径上,把接收端淘汰的乱序包也统计成"丢包"了。

解决:统一改成 import config,然后以 config.LOSS_RATE 的方式引用,保证每次读取都是最新值;丢包模拟只保留在发送端的 sendto 之前,接收端不要加同样的判断逻辑,否则一次丢包被算两次,实际丢包率翻倍。改完用固定种子的脚本连续跑三轮,确认重传计数恒为 0,再继续后面的实验。

5.3 现象:双向传输时,接收文件里混进乱码

现象:升级到双工模式后,client_data.txt 里出现一段段和源文件毫无关系的乱码,乱码位置不固定,但每次都出现在传输的中后段。

原因:ACK 包和数据包共用端口,接收线程没有先解析标志位,把 ACK 报文当作数据写入了文件。ACK 包虽然没有有效 payload,但内存缓冲区里上次 sendto 留下的残留数据被一并写进去了。

解决:parse_packet 里先读标志位字段,0x02 的包直接走确认处理分支,继续等待数据包;同时要求构造 ACK 报文时把 payload 区域清零,双重保险。这两个改动加起来不到十行,但能根治这类"看起来随机出现"的乱码。排查这类问题最快的办法是打印出每个包的 flag 值和头部 8 字节,和源文件开头对一下就清楚了。

5.4 现象:diff 显示文件中间缺一段,但两端都正常退出

现象:传输结束,服务端和客户端都正常打印了完成信息,文件 diff 却发现中间缺了一整块数据,不是末尾而是中间。

原因:发送循环的退出条件是 base == len(packets),但最后一个分块发出后,它对应的 ACK 还没回来,接收端也还没把最后一块写盘,发送端就退出并关闭了 socket——注意抢先 close 的是发送端,客户端最后一次 recvfrom 直接返回异常,最后一块数据在落盘之前就丢了。

解决:发送端在发完所有分块后,先发一个特殊结束标志包(比如 seq 设为 0xFFFF),接收端收到后先把文件 flush 再退出;或者发送端退出前 sleep 一个超时周期,等最后一个 ACK 回来再关 socket。前者更干净,报告里描述也更正规,我最终固定用标志包方案,sleep 方案只拿来临时验证时用。

5.5 现象:WINDOW_SIZE 调大,吞吐量反而下降

现象:把 WINDOW_SIZE 从 4 调到 16,理论上在途包变多吞吐应该上升,实测反而下降,偶尔还会出现接收端缓冲区溢出的报错。

原因:接收端是单线程同步写文件,大批包同时到达后处理不过来,UDP 接收缓冲区被填满,后面的包被操作系统直接丢弃,触发大量重传;重传的包再次涌入,又加剧拥塞,吞吐直接崩掉。这是 GBN 协议在"窗口大于接收端处理能力"时典型的震荡现象。

解决:文件写入改成攒一段再批量写盘,减少磁盘 IO 的阻塞时间;同时调大 socket 接收缓冲区,接收端初始化时执行 sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 65536)。跑实验时先小窗口验证正确性,再逐步加大窗口测吞吐,记录每组窗口下的接收耗时,画一条曲线出来,能清楚看到吞吐拐点在哪里——这个拐点就是你报告里"窗口大小对吞吐影响"这一节的核心数据。

6. 二次开发:把 GBN 改造为 SR 协议的具体操作与验证

如果课设最后一步是把 GBN 改进为 SR,你已经站在第三章代码的基础上了。改造点一共四个,按顺序做不容易乱。

第一步,改接收端。把 client.py 里"seq != expected 直接丢弃"改成先暂存进缓存字典,同时每个包单独回 ACK:

def sr_recv_loop(sock, recv_file): base, rcv_buf = 0, {} while True: pkt, addr = sock.recvfrom(BUF_SIZE) seq, flag, data = parse_packet(pkt) if flag == FLAG_DATA and base <= seq < base + WINDOW_SIZE: rcv_buf[seq] = data send_ack(sock, addr, seq) # 独立确认,回当前包序号 while base in rcv_buf: # 连续则交付并推进窗口 recv_file.write(rcv_buf.pop(base)) base += 1

第二步,改发送端。GBN 里收到的 ACK 直接 max 推进 base,SR 要改成按序号记录确认状态,只推进连续的前缀:

acked = set() # 已确认的包序号集合 # 收到 ACK 后 acked.add(ack_seq) while base in acked: # 只推进连续的前缀 base += 1

第三步,改超时重传。把 range(base, next_seq) 全量重发改成只重发超时的那一个包。这一步是 SR 性能优势的核心,也是报告里对比 GBN 和 SR 最硬的数据来源。

第四步,验证。固定 random.seed 后,在相同丢包率下分别跑 GBN 和 SR,统计两个指标:总发送调用次数(含重传)和文件 diff 结果。预期是丢包率越高,SR 的重传次数越明显少于 GBN;两个协议的文件 diff 都应为空。

我自己的习惯是每次改完协议,强制走一遍"丢包率 0.1 + 固定随机种子 + 文件 diff + 重传计数"四件套,确认改对之后再动下一步代码。这个习惯让我后来在改定时器策略、改窗口自适应时少踩了很多坑——每次翻车都能快速定位到是这一步引入的问题,而不是从头怀疑整个协议框架。这套源码包里 README 和 report.docx 已经把运行步骤和实验记录模板整理好了,按上面的顺序改完,你的课设从 GBN 到 SR 的演进就有一份完整可复现的实验记录了,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进