TCP Web ATM(多客戶端並行 + System V Semaphore 防止 Race Condition)
此專案主要練習多執行緒對於共享資料的存取,以及如何避免Race Condition:
- Server:提供一個「只有一個帳戶」的 ATM 服務,多個 client 可以同時連線並執行 deposit / withdraw。
- Client:送出一次指令(動作 / 金額 / 次數),由 server 反覆執行並在每次操作後印出餘額。
- 重點:在多執行緒(multi-threading)同時存取共享資料(帳戶餘額)時,使用 System V semaphore 保護 critical section,避免 race condition。
- 功能與特色
- 專案結構
- 通訊協定
- 編譯與執行
- demo.sh 一鍵展示
- Linux / OS 觀念整理
- 程式碼導讀
- 常見問題與除錯
- 限制與可改進點
- 背景 / 來源
- server2.c(Process + Shared Memory 版本)
- TCP socket server/client(BSD sockets API)
- Server 支援 多客戶端同時連線:採用「一連線一執行緒」模型(thread-per-connection)
- 使用 System V semaphore(
semget/semop/semctl)做 mutual exclusion demo.sh使用 tmux 同時啟動 server 與多個 client,觀察「存提款交錯」與餘額變化
.
├── server.c # TCP server:accept 後 per-connection thread 處理
├── server2.c # TCP server:accept 後 fork() 出 child-process 並以 shared memory 進行同一ATM帳戶的存取
├── client.c # TCP client:送出 deposit/withdraw amount times
├── Makefile # 編譯 client/server
└── demo.sh # tmux demo:一次跑 server + 多 client 併發
Client 送給 server 的 payload 是純文字(ASCII),格式:
<action> <amount> <times>\n
<action>:deposit或withdraw<amount>:每次存/提款金額(整數)<times>:重複執行次數(整數)
Server 端在每次更新 balance 後輸出:
After deposite: <balance>
After withdraw: <balance>
註:原始程式碼中的字串為
After deposite(拼字),如果要更精準可改成After deposit。
- Linux(建議 Ubuntu / Debian 系 VM)
gcc,maketmux(只在跑demo.sh時需要)
make會產生:
./server./client
關於 pthread 連結旗標:
- 在較新的 glibc(例如 2.34+)環境,pthread 已經合併進 libc,沒有
-pthread也可能編得過。- 但為了可攜性(例如較舊的 Linux 發行版),建議在 Makefile 的 server target 加上
-pthread。
./server <port>
# e.g.
./server 8888./client <ip> <port> <deposit|withdraw> <amount> <times>
# e.g.
./client 127.0.0.1 8888 deposit 1 500demo.sh 會:
make clean && make- 用 tmux 開一個 session
- 同時跑 1 個 server + 4 個 client(不同 deposit/withdraw 參數)
使用方法:
chmod +x demo.sh
./demo.shtmux 內看到 server 印出餘額交錯變化,即可展示「多 client 併發 + semaphore 保護」的效果。
下面用「Linux OS 程式設計師」的角度,把這份程式背後的核心概念說清楚。
在 Linux 裡,socket 也是一種 file descriptor (fd)。
socket()回傳一個整數 fdconnect()/bind()/listen()/accept()都是對這個 fd 做操作read()/write()/close()也同樣適用
這種「萬物皆檔案(everything is a file descriptor)」的抽象,讓網路 I/O 跟檔案 I/O 可以共用同一套系統呼叫語意。
server.c 的典型流程:
socket(AF_INET, SOCK_STREAM, 0)建立 TCP socketbind()綁定本機 IP/portlisten()進入被動等待accept()接受連線,得到「新的」連線 fd(CFD)
client.c 的流程:
socket()connect()對 server 建立 TCP 連線write()把指令送出shutdown(SHUT_WR):關閉「寫入方向」,讓 server 的read()最終會得到 EOF(回傳 0)
server 使用的是常見的 thread-per-connection:
- main thread 在
accept()loop 裡不斷接新連線 - 每接到一個連線就
pthread_create()一條 worker thread - worker thread 負責該 client 的讀取與處理
pthread_detach():讓 thread 結束後資源自動回收(不需要pthread_join())
OS 層面來看:
- 每個 thread 都有自己的 user-space stack、register context
- Linux scheduler 會在不同 thread 間做 time-slicing(可被搶佔)
- 所以「看似」同一段 C 程式中的
balance += amount,在多 thread 下其實可能被切成多步驟執行並交錯
balance 是全域變數、所有 thread 共用:
int balance = 0;如果兩個 client 同時做 deposit/withdraw,而你沒有鎖住更新區段,可能出現:
- read-modify-write 被交錯
- 最終 balance 結果錯誤(丟失更新 / 覆蓋更新)
這段更新 balance 的區域就是典型 critical section。
本專案用 System V semaphore 做 mutual exclusion。
semget()建立的 semaphore 是由 kernel 管理的 system-wide object- 不只是 threads,連不同 processes 只要 key 相同也能取得同一把 semaphore
P(sem); // lock:sem_op = -1
... // critical section
V(sem); // unlock:sem_op = +1semop() 在核心中以原子方式完成「檢查 + 修改」:
- 若值為 0,
P()會 block(睡眠等待),直到別人V() - 這就是 semaphore 能解決競爭的根本原因
System V semaphore 不是跟著 process 自動消失(除非你明確移除):
- 正常 Ctrl+C 時,程式用
semctl(IPC_RMID)移除 - 但如果程式 crash/kill -9,semaphore 可能殘留
你可以用:
ipcs -s # 查看目前系統的 semaphore
ipcrm -s <id> # 手動移除這也是作業規格特別要求「結束前要清 semaphore」的原因。
在「同一個 process 內的 threads」保護共享變數,pthread_mutex_t 通常更輕量、語意更貼近。
本作業使用 System V semaphore 主要是在練 OS IPC/同步機制:
- 以熟悉 kernel-managed semaphore 與
ipcs/ipcrm等工具
server 註冊了:
signal(SIGINT, cleanSocketSemaphore);當你 Ctrl+C:
- 會收到 SIGINT
- handler 會
close(listenFd)並semctl(IPC_RMID)移除 semaphore
OS 角度:
- Signal 是一種非同步事件,可能在任何時刻打斷你的程式
- 正式產品通常會用更安全的方式處理(例如設旗標、讓主迴圈安全退出),因為 signal handler 能做的事有限(async-signal-safe 限制)
- 但在 Lab 情境下這樣做足以展示「如何確保 IPC 物件被清理」
socket_Listen(port):建立 listen socket、bind()、listen()accept_Connection():accept()成功後動態配置一個int* clientFd,把 fd 指標傳給 threadworkerThread(void *arg):read(CFD, recBuf, BUFFER_SIZE-1)讀入指令sscanf(recBuf, "%9s %d %d", action, &amount, ×)解析- 迴圈
times次:P(sem)→ 更新 balance →V(sem)usleep(200)讓輸出更容易交錯展示
cleanSocketSemaphore():收到 SIGINT 時清除 semaphore(IPC_RMID)
- 解析 CLI 參數:
<ip> <port> <deposit/withdraw> <amount> <times> socketInit():socket()→inet_pton()→connect()snprintf(sendBuf, ...)組出訊息字串write(sock, sendBuf, BUFFER_SIZE)把 buffer 送出shutdown(sock, SHUT_WR)送出 EOF
- 用 tmux 做多視窗展示(server + 多 client 同時操作)
- 方便 Demo「多連線並行」與餘額交錯
make:編譯出client與server- 建議改善:對 server 加上
-pthread(可攜性)
因為程式用 IPC_CREAT | IPC_EXCL,如果上一次 semaphore 沒清掉,下一次會建立失敗。
解法:
ipcs -s
ipcrm -s <semid>可能是 port 還在 TIME_WAIT 或還有舊的 process 佔用。
解法:
- 換 port
- 或在 server 加上
setsockopt(..., SO_REUSEADDR, ...) - 或找出佔用者:
ss -ltnp | grep <port>
sudo apt-get install tmux這份程式是針對特定OS觀念作練習,功能刻意簡化,以下是比較「OS/網路程式設計」角度的可改進點:
- client 寫入長度:目前用
write(..., BUFFER_SIZE)固定送 256 bytes(大多是\0),可改成write(sock, sendBuf, strlen(sendBuf))。 - TCP 的 partial read/write:在真實網路上
read()/write()可能只處理部分資料,應該設計 framing(例如以\n為分隔)並用迴圈收齊。 - server 端回覆:目前 server 只印在 stdout,不回傳結果給 client;可以加上
write(CFD, ...)回傳餘額。 - 負餘額/溢位:沒有檢查 withdraw 造成負值、也沒有檢查 int overflow。
- 同步機制選擇:如果只是 thread 間保護共享變數,
pthread_mutex會更直覺;System V semaphore 則適合跨 process 的同步。 - signal handler 安全性:正式產品會避免在 handler 直接呼叫非 async-signal-safe 的函式。
本專案依據嵌入式作業系統課程中實驗之規格完成,目標是練習:
- Linux socket programming
- 多執行緒併發
- semaphore 同步與 race condition 處理
server2.c 是同一個 ATM 題目的另一種經典 OS 作法,若要使用此 server2.c 請自行修改 Makefile:
- 並行模型:process-per-connection
- 主行程
accept()後立刻fork() - child process 負責處理該 client 的指令(讀一次指令、執行
times次) - parent process 關閉連線 fd、回到
accept()迴圈
- 主行程
跟 thread 版本不同:
- Thread 共享同一個位址空間,所以全域變數
balance天然共享。 - Process
fork()後各自有獨立位址空間(預設 不共享,靠 copy-on-write 讓一開始看起來「像共享」,但寫入後就分離)。
因此 server2.c 用 System V shared memory 把「帳戶餘額」變成 kernel 管理的共享區段:
shmget(SHM_KEY, SHM_SIZE, IPC_CREAT | 0666):建立/取得共享記憶體段shmat(shm_id, NULL, 0):映射到每個 process 的 user-space 位址空間balance_shm指向共享的int,child/parent(以及多個 child)都看得到同一份餘額
OS 觀點:shared memory 是 IPC 中「最快的一種」,因為資料不需要在 process 間複製; 代價是「同步」要自己做(不然一樣會 race)。
即使 balance 放到 shared memory,多個 child process 同時更新仍會出現 race condition。
server2.c 用 System V binary semaphore 做跨 process 的 mutual exclusion:
semget(semKey, 1, IPC_CREAT | IPC_EXCL | 0666)建立 semaphoresemctl(SETVAL)設初值為 1P()/V()透過semop()以原子方式做 lock/unlock
程式的 critical section 在這裡(每次存/提款):
P(sem)→ 更新*balance_shm→V(sem)
fork() 後 檔案描述子會被複製(更精確說:父子各自的 fd table entry 指向同一個 open file description),所以要做正確的關閉:
- child:
close(listenFd)(child 不需要 listen socket) - parent:
close(*clientFd)(parent 不處理該連線)
這能避免:
- listen socket 被多個 process 持有造成關閉不乾淨
- 連線 fd 因為父子都握著而延後 EOF/資源釋放
child 結束後,如果 parent 不 wait(),會產生 zombie。
server2.c 註冊 SIGCHLD handler:
waitpid(-1, NULL, WNOHANG)迴圈 reaping 已結束的 child
SIGINT handler cleanSocketSemaphore() 會做:
semctl(sem, 0, IPC_RMID, ...)移除 semaphoreshmdt(balance_shm)detachshmctl(shm_id, IPC_RMID, ...)移除 shared memory
如果程式異常退出導致殘留,可以用:
ipcs -s # semaphore
ipcs -m # shared memory
ipcrm -s <semid>
ipcrm -m <shmid>
---