Step 78. Socket Communication 2 — A Multi-Client Chat Server
Level 1 — Programming and the Computer’s Insides | Difficulty ★★★★☆ | Estimated time: 4 hours
Prerequisites: Step 77 complete; you know the socket/bind/listen/accept/connect flow and encode/decode. You handle Python functions and lists comfortably.
- What you need: Python, three or more terminals (1 server + 2 guests). Bring the Step 77 echo server code beside you.
- Caution: today’s experiments are also all safe practice happening only at 127.0.0.1, inside my own computer.
Last chapter’s echo server had a fatal limitation: while it talked with one guest, other guests waited outside the door. Real services solve this with the idea of "stationing one employee per connection." That employee inside a program is the thread (a flow of execution). Today we build a chat server where multiple clients chatter simultaneously using threads, and along the way we step directly on the trap that "sharing" breeds — the race condition.
1. Learning Objectives
By the end of this chapter, you will be able to:
- Explain that a thread is "a flow of execution sharing memory inside the same process"
- Build the server structure that launches a thread per connection (gatekeeper + dedicated staff + roster)
- Implement broadcast (to everyone except the sender) and exit handling
- Reproduce a race condition experimentally and state why it is a seed of vulnerabilities
- Design exception handling on the premise that "guests vanish without notice"
2. Background Knowledge — Today’s Tools and Concepts
Today’s Tools at a Glance
| Category | Details |
|---|---|
| Language/environment | Python 3 standard library socket + threading (no installation), three terminals |
| Today’s functions | threading.Thread(target=..., args=...), .start(), threading.active_count(), .join() |
| Concepts needed | Threads, shared memory, broadcast, race conditions, daemon threads |
| Today’s output | chat_server.py + chat_client.py — your first multi-user system where many chatter at once |
2-1. Threads — Multiple Hands Inside One Program
Until now, our programs ran "line by line, top to bottom" — a single flow of execution. A thread is a technique that replicates this flow into several. If Step 68’s fork duplicated a whole process, a thread divides only the execution flow inside the same process. That’s why threads share variables and lists with one another — the picture is several cooks using one kitchen.
import threading
t = threading.Thread(target=function, args=(ingredient,))
t.start()
This is the command "start this function as a separate flow of execution." Once start is called, that function runs simultaneously with the original flow.
2-2. The Chat Server’s Blueprint
The structure of the server we build today.
- The main flow only repeats accept — every time a guest arrives
- It puts the connected guest’s socket (conn) onto the roster of connected users (a list)
- It launches one dedicated thread for that guest and assigns it "listening to this guest’s words"
- Whenever the guest speaks, the thread relays the content to everyone on the roster (broadcast)
- When the guest leaves, it erases them from the roster
The main flow is the "gatekeeper," and the threads are "dedicated staff." The gatekeeper only receives guests; all conversation passes to the staff.
2-3. A Taste of Race Conditions
What happens when threads touch the same data at the same time? If thread A is "in the middle of reading the roster" and thread B erases someone from the roster at that very moment, A fails while trying to speak to someone who no longer exists. A problem like this, where the result differs depending on the timing of execution order, is called a race condition. We reproduce it ourselves in 3-6.
3. Follow Along
3-1. The Chat Server’s Skeleton
Create chat_server.py.
Input (chat_server.py)
import socket
import threading
clients = [] # roster of connected users (shared by all threads)
def handle(conn, addr):
name = addr[0] + ":" + str(addr[1])
print("[joined]", name, flush=True)
while True:
try:
data = conn.recv(1024)
if not data:
break
msg = data.decode()
print(f"[{name}] {msg.strip()}", flush=True)
broadcast(f"[{name}] {msg}", conn)
except Exception:
break
clients.remove(conn)
conn.close()
print("[left]", name, flush=True)
def broadcast(message, sender):
for c in clients:
if c is not sender:
try:
c.sendall(message.encode())
except Exception:
pass
s = socket.socket()
s.bind(("0.0.0.0", 9999))
s.listen()
print("Chat server open (port 9999)", flush=True)
while True:
conn, addr = s.accept()
clients.append(conn)
threading.Thread(target=handle, args=(conn, addr)).start()
print("Current thread count:", threading.active_count(), flush=True)
How to read it: the main flow (the while at the bottom) only accepts connections, puts them on the roster, and launches threads. All conversation is the handle threads’ job. broadcast relays "to everyone except the sender." The try/except scattered around are safety devices so the server doesn’t die no matter when a guest suddenly leaves.
Why: this structure — gatekeeper + dedicated staff + roster — is a miniature of game servers and messenger servers. Only the size differs; the skeleton is the same.
3-2. Making Guests and the First Chat
This time the client too must do "listening and speaking" simultaneously. Here is chat_client.py.
Input (chat_client.py)
import socket
import threading
s = socket.socket()
s.connect(("127.0.0.1", 9999))
def listen():
while True:
try:
data = s.recv(1024)
if not data:
break
print(data.decode().strip())
except Exception:
break
threading.Thread(target=listen, daemon=True).start()
print("Entered the chat room! Type quit to exit")
while True:
msg = input()
if msg == "quit":
break
s.sendall(msg.encode())
s.close()
Input: run the server in terminal 1 → run a client each in terminals 2 and 3 → type alternately on both sides.
Output (measured 2026-09-09 — guest A joined first and said "Hello, this is A"; then guest B joined and said "Nice to meet you, this is B" and "Goodbye"):
Guest A’s screen:
[127.0.0.1:52429] Nice to meet you, this is B
[127.0.0.1:52429] Goodbye
Server screen:
Chat server open (port 9999)
[joined] 127.0.0.1:52428
Current thread count: 2
[127.0.0.1:52428] Hello, this is A
[joined] 127.0.0.1:52429
Current thread count: 3
[127.0.0.1:52429] Nice to meet you, this is B
[127.0.0.1:52429] Goodbye
[left] 127.0.0.1:52428
[left] 127.0.0.1:52429
How to read it: notice three things. ① What B said arrived on A’s screen — broadcast worked. ② Conversely, the "Hello" A said earlier is not on B’s screen — it was said before B joined, so there was no reason for it to go to B. ③ One thread grew per connection, making 2 (main + A), then 3 (main + A + B).
Why: the client uses a thread too. While the listen thread takes exclusive charge of "hearing words from the server," the main flow receives keyboard input. daemon=True is a marker meaning "an auxiliary thread that dies together when the main ends." You have just built a program in which "sending and receiving happen at the same time."
3-3. Predict — Does the Echo Go to the Sender Too?
Time to predict. If you erase the if c is not sender: condition in broadcast (so it also sends to the speaker), what happens on the speaker’s screen? Predict, then erase it and check.
Check yourself (measured 2026-09-09): with the condition turned off, when A said "Hello," this was printed on A’s screen too.
A's screen: [127.0.0.1:60022] Hello
How to read it: what you typed and what was delivered show up doubled, which is awkward. That’s why broadcast usually excludes the sender.
Why: a small experiment showing that the design of "whom to send to" determines the user experience. Server development is communication technology and, at the same time, the design of conversation.
3-4. Predict — What If One Person Force-Quits?
Time to predict. If you force-quit one chatting client with Ctrl+C, what happens to the server? ① the server dies too ② only that guest vanishes from the roster ③ the other guests are kicked off too.
Check yourself: experiment, and the server log prints "[left]" while the remaining guests happily keep chatting. In the 3-2 measurement log as well, both guests’ exits were recorded individually. This works thanks to handle’s try/except and the "disconnection = empty data" rule handling the exit.
Why: designing on the premise that "guests vanish without notice" is a server’s basic posture. A network counterpart can vanish in every possible way — power outage, force-quit, communication loss. Exception handling is a server’s survival instinct.
3-5. Race Condition Experience 1 — It Crashes Less Than You’d Think
Let’s step on the trap ourselves. Create race.py.
Input (race.py)
import threading
counter = 0
def work():
global counter
for _ in range(100000):
counter += 1
threads = [threading.Thread(target=work) for _ in range(10)]
for t in threads: t.start()
for t in threads: t.join()
print("Result:", counter)
Input: run it several times.
python race.py; python race.py; python race.py
Output (measured 2026-09-09, five runs):
Result: 1000000
Result: 1000000
Result: 1000000
How to read it: 10 threads each adding 100,000 times keeps producing the correct million. Huh? Didn’t you say race condition? — if that’s how you feel, that’s normal. Python switches threads only at very short intervals, and one counter += 1 finishes far faster than that interval, so collisions rarely happen. A bug whose result depends on timing is characterized by "being hard to reproduce." This is what makes race conditions scary — they’re fine in testing, then blow up once in a while in production.
3-6. Race Condition Experience 2 — Widen the Gap and It Crashes
Same code, but we deliberately widen the gap between "read → add → write." Here is race2.py.
Input (race2.py)
import threading, time
counter = 0
def work():
global counter
for _ in range(20000):
v = counter # read
time.sleep(0) # yield a chance to other threads
counter = v + 1 # write the added value
threads = [threading.Thread(target=work) for _ in range(10)]
for t in threads: t.start()
for t in threads: t.join()
print("The answer is 200000, result:", counter)
Output (measured 2026-09-09, three runs):
The answer is 200000, result: 20575
The answer is 200000, result: 20489
The answer is 200000, result: 20607
How to read it: only about 10% of the correct 200,000 survived. counter += 1 is in fact three actions — "read → add → write" — and if another thread squeezes in between one thread’s read and write, additions evaporate. sleep(0) merely widened that gap; the bug’s structure is the same as 3-5’s. And notice the result differs with every run (20575, 20489, 20607) — this nondeterminism itself is the diagnostic clue.
Why: the same thing can happen with the chat server’s clients list. That’s why real systems enforce "only one thread at a time" with a lock. And when this principle blows up in an operating system or a server program, it becomes a vulnerability leading to privilege escalation. Today it’s a playful number error, but remember that the same structure is serious attack material.
4. Missions & Exercises
Mission — Raising the Chat Server’s Completeness
- Nicknames: treat the first message after joining as the nickname, and broadcast afterward in the form "[nickname] message"
- Join/leave notices: when someone enters or leaves, broadcast "○○ has joined" to everyone
- Capacity limit: cap simultaneous connections at 5; on overflow, send "The room is full" and disconnect
- Apply a lock: put a threading.Lock() on the places that touch the clients list (add/remove/broadcast), and summarize in comments the difference before and after
- Reflection: based on the race2.py experiment, write in three sentences "why the chat server needs a lock"
Exercises
Q1. Explain the difference between threads and processes from the perspective of "what they share."
Q2. Explain why the chat server’s main flow only does accept and delegates conversation to threads, together with the problem that arises if the main converses directly.
Q3. In the 3-2 measurement, why was what A said first absent from B’s screen? Is this a bug or a design?
Q4. Comparing race.py (the correct million) with race2.py (collapsed results), explain why race-condition bugs are hard to discover in testing.
5. Model Answers & Completion Criteria
Mission Model Answer
The skeleton of a server with nicknames, join/leave notices, and a lock:
import socket
import threading
clients = {} # socket -> nickname
lock = threading.Lock()
def broadcast(message, sender=None):
with lock:
targets = [(c, n) for c, n in clients.items() if c is not sender]
for c, _ in targets:
try:
c.sendall(message.encode())
except Exception:
pass
def handle(conn, addr):
try:
nickname = conn.recv(1024).decode().strip() or str(addr[1])
with lock:
if len(clients) >= 5:
conn.sendall("The room is full".encode())
conn.close()
return
clients[conn] = nickname
broadcast(f"{nickname} has joined", conn)
while True:
data = conn.recv(1024)
if not data:
break
broadcast(f"[{nickname}] {data.decode().strip()}", conn)
except Exception:
pass
finally:
with lock:
nickname = clients.pop(conn, None)
conn.close()
if nickname:
broadcast(f"{nickname} has left")
s = socket.socket()
s.bind(("0.0.0.0", 9999))
s.listen()
print("Chat server open (port 9999)", flush=True)
while True:
conn, addr = s.accept()
threading.Thread(target=handle, args=(conn, addr), daemon=True).start()
Three key points: ① the roster became a dictionary so it remembers the nickname together. ② Touch the roster inside with lock:, first copy the broadcast target list, then sendall outside the lock — holding a lock long makes other threads stall. ③ Exit handling goes in finally, so the roster gets cleaned no matter which path the function exits through.
An example of the item-5 reflection: "In race2.py, another thread squeezed in between read and write and additions evaporated. The chat server’s clients has the same structure if another thread modifies it mid-broadcast. A lock is the device that guarantees ‘nobody can squeeze in while reading and writing.’"
How to verify: ① Is the broadcast shown with nicknames? ② Does the 6th connection receive "The room is full" and get disconnected? ③ Does a force-quit still produce a leave notice while the server lives? ④ Do the three reflection sentences mention the race2 experiment’s numbers? If all four are "yes," it’s complete.
Exercise Solutions
Q1 solution. Processes each have independent memory (variables, lists), but threads share the memory inside the same process. So threads can see and change each other’s variables directly — convenient, but also the reason things tangle when touched simultaneously.
Q2 solution. If the main flow enters direct conversation (a recv loop), it can’t return to accept until the conversation with that guest ends, so it can’t accept the next guest’s connection. If accept is the main’s sole devotion and conversation is delegated to a new thread per connection, new guests can enter at any time.
Q3 solution. broadcast sends only to "the people currently on the roster." At the moment A spoke, B hadn’t joined yet and wasn’t on the roster, so B couldn’t receive it. It’s not a bug but a design — most chat services also either don’t show pre-join conversation (this design) or store it separately and show it (another design).
Q4 solution. A race condition blows up "only when the timing aligns." If the collision window is narrow like race.py, the correct answer comes out even across tens of thousands of runs and the test passes; only when the window widens like race2 does it show. Real-world load (slow disks, many users) widens that window, which is how accidents of the "it was fine in testing" kind happen.
Completion Criteria Checklist
- [ ] I can explain what a thread is (a flow of execution with shared memory)
- [ ] I can build the server structure that launches a thread per connection
- [ ] I can implement broadcast and exit handling
- [ ] I reproduced a race condition with race2.py and observed the nondeterminism
- [ ] I succeeded at a chat with 2 or more simultaneous connections
- [ ] Mission: I completed nicknames, join/leave notices, and the lock
6. Common Pitfalls & Fixes
Wall 1. When one person speaks, the server stalls
Symptom: conversation works with the first guest, but a second guest can’t connect.
Cause: you called handle directly in the main flow instead of launching it as a thread. The main is tied up in conversation and can’t return to accept.
Fix: inside the main while, do only threading.Thread(...).start(). start is "launch and move on immediately"; a direct call is "wait until it finishes."
Wall 2. An error occurs trying to send to a guest who left
Symptom: after someone leaves, an exception blows up in sendall.
Cause: you spoke to a disconnected connection, because roster cleanup lagged behind the broadcast.
Fix: as in the model answer, copy the broadcast target list first inside the lock, and remove failed connections in exit handling. The try/except inside broadcast is the minimum safety pin.
Wall 3. Messages arrive mashed together
Symptom: two sent messages arrive as one lump, or arrive cut in half.
Cause: TCP is a "stream," not a "bundle of letters." There’s no guarantee that the number of sends matches the number of receives.
Fix: at today’s level, appending a newline to each message and reading line by line on the receiving side is sufficient. Later, when you learn the convention of "send the length first, then the content" (framing), it becomes exact.
Wall 4. The server won’t shut down
Symptom: pressing Ctrl+C responds slowly, or restarting says "address already in use" (Step 77’s WinError 10048).
Cause: interrupt handling can lag while stopped in accept/recv, and live threads remain.
Fix: give threads daemon=True, and make sure the server is definitely terminated after checking. If the port lingers, find the PID with netstat and clean it up.
Wall 5. The race condition won’t reproduce
Symptom: race.py keeps producing only the correct answer (1000000).
Cause: it’s not that the bug is absent — the collision window is narrow (in the 2026-09-09 measurement too, all five runs were correct).
Fix: widen the gap by inserting time.sleep(0) between read and write like race2.py, and the collapse appears (measured 2026-09-09: 20575, 20489, 20607). "It won’t reproduce" and "it’s safe" are different things — that is this wall’s lesson.
7. Summary
Today’s Concepts
| Concept | One-line description |
|---|---|
| Thread | Multiple flows of execution inside one process — they share memory |
| Daemon thread | An auxiliary thread that dies together when the main ends |
| Broadcast | Relaying to everyone connected except the sender |
| Race condition | A bug whose result differs with the timing of execution order — a seed of vulnerabilities |
| Lock | A device that lets "only one thread at a time" touch shared data |
| Nondeterminism | Results differing with every run — a diagnostic clue for concurrency bugs |
Today’s Functions
| Function | What it does |
|---|---|
threading.Thread(target=function, args=(ingredient,)) |
Prepare a new flow of execution |
.start() / .join() |
Start the flow / wait until it ends |
threading.active_count() |
Count living threads |
threading.Lock() / with lock: |
Lock simultaneous access to shared data |
time.sleep(0) |
Yield an execution chance to other threads (for experiments) |
The Instinct That Matters More Than Commands
Data shared by multiple flows of execution, like today’s clients list, carries the debt of "concurrency bugs" as the price of convenience. When this bug happens in a messenger, messages tangle; when it happens in an authentication server, it becomes an accident where someone logs in as someone else. Race-condition vulnerabilities are also CTF staples. All attack practice belongs only in your own lab and legal platforms — today’s chat too happened entirely at 127.0.0.1.
And look at the server screen’s output again. Who entered, what they said, when they left — everything is recorded in time order. Today you became the owner of logs — you experienced, from the operator’s seat, why a messenger’s conversations remain with the service company, and why logs are the treasure vault of incident investigation. The fact that "the server sees everything," and the principle that "those who share bear the duty to lock." These two are today’s real harvest.
Once every box is checked, Step 78 is complete. Click the checkbox in the sidebar to save your progress.