Timeout khiến kết quả phía bên kia chưa rõ
Chạy các ca TCP cục bộ có cùng timeout nhưng khác kết quả phía server; kiểm tra định danh thử lại và việc mất biên nhận chống trùng.

Client ngừng chờ câu trả lời. Server có thể đã thay đổi trạng thái, hoặc sẽ thực hiện thay đổi sau đó. Timeout cho client biết lượt chờ không hoàn thành trong thời gian quy định; nó không cung cấp biên nhận mô tả kết quả ở phía bên kia.
Nếu bạn theo dõi được một yêu cầu và câu trả lời giữa hai chương trình, bài này sẽ giải thích vì sao cần cẩn thận khi thử lại thao tác đã timeout. Ta sẽ chạy thí nghiệm TCP thật trên máy cục bộ. Tác động là cộng một vào bộ đếm trong bộ nhớ. Những điểm chờ có chủ ý tạo ra các lịch sử server khác nhau nhưng cùng một timeout phía client; định danh thao tác giúp kiểm tra tác động bị lặp.
Đặt timeout cạnh thời điểm có tác động
Giả sử yêu cầu là cộng một vào bộ đếm. Trong lịch sử thứ nhất, server đọc yêu cầu rồi chờ trước khi áp dụng. Client timeout khi bộ đếm vẫn bằng không. Sau khi bỏ chặn điểm chờ, server tăng bộ đếm lên một dù client đã đóng kết nối.
Trong lịch sử thứ hai, server tăng bộ đếm trước rồi mới chờ trước khi trả lời. Client lại timeout, nhưng bộ đếm đã bằng một. Kết quả mà client nhận được giống hệt nhau. Chỉ người quan sát trong thí nghiệm, có thể xem trạng thái server, mới biết lịch sử nào đã xảy ra.
Bài của Marc Brooker trong Amazon Builders Library nêu rõ sự phân biệt này: timeout hoặc lỗi không nhất thiết có nghĩa là tác động chưa xảy ra. Bài đó cũng giải thích vì sao thử lại có thể làm tăng tải. Đây là hai vấn đề riêng: trì hoãn lần thử lại có thể giảm áp lực, nhưng chờ lâu hơn không biến kết quả đầu chưa biết thành bằng chứng rằng thao tác thất bại.
Tái tạo hai lịch sử qua loopback
Ví dụ chạy các task client và server riêng trong một tiến trình Python, nối bằng TCP IPv4 loopback. Đây là thí nghiệm socket đã thực thi, không phải phép thử giữa nhiều máy. Một event cố ý giữ handler server đầu tiên. Cơ chế phối hợp trong thí nghiệm chỉ bắt đầu lượt chờ đọc có thời hạn 20 mili giây khi handler ấy đã tới đúng điểm chờ.
Sự phối hợp này giúp tái tạo lịch sử. Client production thường không nhìn thấy điểm nội bộ đó. Đồng hồ cũng chỉ tính thời gian chờ một dòng trả lời; nó không bao gồm thiết lập kết nối và gửi yêu cầu. Hai mươi mili giây là tham số thử nghiệm, không phải giá trị timeout được khuyến nghị cho dịch vụ thực tế.
Tài liệu wait_for của Python nêu rằng khi hết hạn, hàm hủy task đang được chờ rồi phát sinh TimeoutError, đồng thời đợi quá trình hủy kết thúc. Ở đây, thao tác đang được chờ là một lượt đọc cục bộ. Handler server được lập lịch độc lập không phải task đó. Đóng luồng client giải phóng tài nguyên kết nối cục bộ, nhưng chương trình không có giao thức nào đảo ngược lần tăng bộ đếm ở server.
import asyncio
import time
async def run_case(stage, retry):
if stage not in ('before', 'after'):
raise ValueError('stage must be before or after')
if retry not in ('none', 'new-token', 'same-token', 'forget-receipt'):
raise ValueError('unknown retry mode')
if retry == 'forget-receipt' and stage != 'after':
raise ValueError('forgetting is tested after the first effect')
release = asyncio.Event()
received = asyncio.Event()
handlers, events, errors = [], [], []
receipts = {}
counter = 0
start = time.monotonic_ns()
def record(side, event, token=''):
events.append(dict(side=side, event=event, token=token, counter=counter,
elapsed_ms=(time.monotonic_ns() - start) / 1_000_000))
async def handle(reader, writer):
nonlocal counter
handlers.append(asyncio.current_task())
first = len(handlers) == 1
try:
token = (await reader.readline()).decode('ascii').rstrip('\n')
if token not in ('op-1', 'op-2'):
raise ValueError('this fixture accepts only its two fixed tokens')
record('server', 'received', token)
if first:
received.set()
if stage == 'before':
record('server', 'held before effect', token)
await release.wait()
# No await between checking, changing state and recording a receipt.
if token not in receipts:
counter += 1
receipts[token] = counter
record('server', 'effect applied', token)
else:
record('server', 'duplicate suppressed', token)
result = receipts[token]
if first and stage == 'after':
record('server', 'held after effect', token)
await release.wait()
record('server', 'reply attempted', token)
writer.write(f'{result}\n'.encode('ascii'))
await writer.drain()
except ConnectionError:
record('server', 'peer connection closed')
except Exception as error:
errors.append(repr(error))
finally:
writer.close()
try:
await writer.wait_closed()
except ConnectionError:
pass
server = await asyncio.start_server(handle, '127.0.0.1', 0, limit=256)
port = server.sockets[0].getsockname()[1]
async def call(token, timeout, synchronize=False):
reader, writer = await asyncio.open_connection('127.0.0.1', port)
try:
record('client', 'sent', token)
writer.write((token + '\n').encode('ascii'))
await writer.drain()
if synchronize:
# Laboratory gate: start the read timer after the server pauses.
await received.wait()
record('client', 'reply wait started', token)
try:
reply = await asyncio.wait_for(reader.readline(), timeout)
except TimeoutError:
record('client', 'timeout', token)
return 'timeout'
if not reply:
raise RuntimeError('unexpected EOF in this control')
record('client', 'reply received', token)
return 'reply:' + reply.decode('ascii').strip()
finally:
writer.close()
await writer.wait_closed()
try:
first_call = asyncio.create_task(call('op-1', 0.02, True))
await asyncio.wait_for(received.wait(), 2)
first_outcome = await asyncio.wait_for(first_call, 2)
assert first_outcome == 'timeout'
observed_at_timeout = counter # Laboratory observer, not client knowledge.
retry_outcome = None
if retry != 'none':
if retry == 'forget-receipt':
receipts.clear() # Keep the effect, deliberately lose its receipt.
record('observer', 'receipts forgotten')
token = 'op-2' if retry == 'new-token' else 'op-1'
retry_outcome = await asyncio.wait_for(call(token, 1), 2)
release.set()
await asyncio.wait_for(asyncio.gather(*handlers), 2)
assert not errors, errors
return dict(stage=stage, retry=retry, first_outcome=first_outcome,
observed_at_timeout=observed_at_timeout,
retry_outcome=retry_outcome, final_counter=counter,
receipts=dict(receipts), events=events)
finally:
release.set()
server.close()
await server.wait_closed()
await asyncio.gather(*handlers, return_exceptions=True)
Mỗi dòng gửi tới server mang một định danh thao tác. Handler chỉ nhận hai định danh cố định dùng trong bộ thử. Nó tăng bộ đếm một lần cho mỗi định danh và lưu giá trị kết quả trong từ điển biên nhận. Các bước kiểm tra từ điển, thay đổi bộ đếm và ghi biên nhận không có await, nên task khác trên cùng event loop không thể chen vào giữa chúng.
Đây là mô hình đồng thời có phạm vi hẹp. Nó không có lưu trữ bền vững và không phối hợp giữa các tiến trình worker. Nó cũng không nhận payload thao tác tùy ý, xác thực người gọi hay cài đặt dịch vụ RPC tổng quát. Mục đích là làm rõ thứ tự quanh một tác động.
Xem client thực sự biết được gì
Đoạn tiếp theo chạy bảy ca. Sáu ca đầu kết hợp hai vị trí chờ với không thử lại, thử lại bằng định danh mới và thử lại bằng cùng định danh. Ca cuối cố ý quên các biên nhận nhưng giữ bộ đếm. Cột bộ đếm tại timeout được người quan sát đọc; giá trị đó chưa từng được trả về cho client đã timeout.
async def experiment():
rows = []
for stage in ('before', 'after'):
for retry in ('none', 'new-token', 'same-token'):
rows.append(await run_case(stage, retry))
rows.append(await run_case('after', 'forget-receipt'))
for row in rows:
print(row['stage'], row['retry'], row['first_outcome'],
row['observed_at_timeout'], row['final_counter'])
return rows
results = asyncio.run(experiment())
assert [row['observed_at_timeout'] for row in results] == [0, 0, 0, 1, 1, 1, 1]
assert [row['final_counter'] for row in results] == [1, 2, 1, 1, 2, 1, 2]
Hình này chụp một báo cáo HTML lấy dữ liệu từ stdout thực thi của chương trình trên CPython 3.12.3. Nó không phải giao diện dịch vụ. Các lượt chờ hai giây ở nơi khác trong bộ chạy dùng để giới hạn việc phối hợp và dọn tài nguyên; chúng không đổi thời hạn đọc câu trả lời hai mươi mili giây.
Ghi thành công ở máy cục bộ cũng yếu hơn xác nhận ở tầng ứng dụng. Tài liệu streams mô tả drain là cơ chế điều tiết bộ đệm ghi. drain trả về không chứng minh phía bên kia đã áp dụng thao tác được yêu cầu. Tương tự, sự kiện server mang nhãn “reply attempted” không chứng minh client đã nhận hoặc hiểu câu trả lời.
Xác định lần thử lại có cùng thao tác hay không
Sau cả hai loại timeout, bỏ chặn handler đầu mà không thử lại sẽ để lại một lần tăng. Thử lại bằng op-2 yêu cầu server xử lý một định danh khác, nên bộ đếm cuối bằng hai. Người gọi có thể chỉ định thực hiện một hành động nghiệp vụ, nhưng server đã được cấp hai định danh thao tác.
Dùng lại op-1 khiến tổng số lần tăng vẫn là một, miễn là biên nhận còn được giữ. Yêu cầu lặp nhận giá trị gắn với lần áp dụng trước. Điều này minh họa cách chặn tác động trùng cho một hành động logic; nó không biến hai lần tăng riêng biệt có chủ ý thành cùng một thao tác.
Giao thức thực còn phải gắn định danh với người gọi và các tham số dự định. Vô tình dùng lại định danh cho số tiền khác hoặc tài nguyên khác không nên âm thầm biến hành động mới thành bản sao. Bộ thử chỉ có thao tác cố định “cộng một”, nên không kiểm tra xử lý xung đột payload.
Lượt thử lại có thể vượt handler đầu
Ca before/same-token đặc biệt hữu ích. Handler gốc đang chờ trước tác động. Client của nó timeout rồi mở kết nối khác mang op-1. Handler thứ hai tới bước kiểm tra biên nhận trước, áp dụng lần tăng và lưu kết quả. Khi handler gốc chạy tiếp, nó tìm thấy biên nhận đó và chặn lần tăng thứ hai.
Vì thế “lần thử đầu” trong log client chưa chắc là lần đầu áp dụng tác động. Quy tắc chống trùng đúng cần một điểm quyết định chung cho mọi lần thử đồng thời của cùng thao tác. Trong ví dụ, điểm đó là chuỗi kiểm tra và cập nhật không bị ngắt trên một event loop. Thêm await giữa kiểm tra và cập nhật sẽ đòi hỏi cơ chế đồng bộ bổ sung cùng lập luận đúng đắn mới.
Quên biên nhận làm thay đổi bảo đảm
Ca thứ bảy áp dụng op-1 trước, rồi giữ câu trả lời tới khi client timeout. Người quan sát xóa từ điển biên nhận nhưng để bộ đếm ở một. Lần thử lại với cùng op-1 lúc này trông như thao tác mới và tăng bộ đếm lên hai.
Đây là phép chủ động gây mất metadata có kiểm soát. Không có tiến trình nào được khởi động lại và không kiểm tra độ bền trên đĩa. Nó cho thấy vấn đề logic khi tác động còn tồn tại nhưng bản ghi chống trùng không còn. Trong thiết kế bền vững, giữ tác động và biên nhận nhất quán là một phần hợp đồng. Giới hạn lưu giữ cũng quan trọng: client không thể mặc định một định danh cũ được bảo vệ mãi sau khi dịch vụ đã quên nó.
Xử lý timeout nên giữ nguyên sự chưa chắc chắn này trong trạng thái bên gọi. “Chưa biết kết quả” thường chính xác hơn “thao tác thất bại”. Tùy giao thức, bước tiếp theo có thể là tra trạng thái bằng định danh thao tác, thử lại hợp lệ bằng cùng định danh hoặc đối soát. Ví dụ không chọn một quy tắc phục hồi dùng chung; nó chỉ ra bằng chứng mà mỗi quy tắc cần có.
Bài tập: xem danh sách sự kiện của before/same-token và xác định handler nào đã áp dụng lần tăng. Sau đó chuyển bước xóa biên nhận tới sau khi mọi lần thử kết thúc, kiểm tra rằng nó không đổi những kết quả đã hoàn thành, trong khi một lần thử lại muộn hơn vẫn có thể lặp tác động. Ghi riêng quan sát của client và trạng thái chỉ người quan sát biết. Sự phân biệt đó ngăn timeout âm thầm trở thành khẳng định về công việc mà client chưa nhìn thấy.




