이번 포스트에서는 thread를 적용한 채팅 프로그램을 구현할 것이다. 전 post에 올라온 단일 thread를 이용한 server-client 구조로 서로 메세지를 주고 받는 실습을 할 수 있었지만 이 프로그램은 치명적인 문제를 가지고 있는데 바로 아래와 같다.
- 서버 프로그램은 두 개 이상의 클라이언트를 받아들일 수 없다.
- 소켓으로 연결된 서버와 클라이언트는 한 쪽이 메세지를 보내면 받고 다시 메세지를 보낼 수 있다. 한 쪽이 일방적으로 여러 메세지를 보낼 수 없다.
그러면 먼저 본격적으로 Mulit-Thread 기반의 채팅 프로그램을 보기 전에 내가 계속 언급한 “thread”는 무엇인가? 알아보고 더 나아가 multi-thread를 적용한 채팅 프로그램까지 post 해보겠당.
내가 생각하기에 thread와 process 단어는 cs 면접에서 두 단어의 차이를 물어보거나 multi-process 프로그래밍과 mulit-thread 프로그래밍의 차이를 물어보는 등 자주 등장하는 주제로 코딩을 조금이라도 해봤다면 다들 한 번은 들어봤다고 생각한다.
음… 일단 먼저 thread에 대해서 짚고 가기 전에 process의 개념에 대해 이해하는 것이 필요하다. 바로 알아보자!
프로세스는 실행 중인 프로그램의 instance를 말한다.
→ 흔히 실행 중인 프로그램이라고 자주 표현
운영체제는 프로세스에게 필요한 자원(CPU 시간, 메모리 등)을 할당하며, 각 프로세스는 독립적인 메모리 공간을 가지고 있어 독립적으로 실행되고 다른 프로세스와는 격리되어 있다.
→ 한 프로세스가 다른 프로세스의 공유 데이터에 직접 접근할 수 없다는 것을 의미
(💡 추가로 process는 하나 이상의 thread를 가진다.)
thread를 정의하는 여러 방식이 존재하는 걸로 아는데 영어 단어 뜻 자체로는 “실”의 의미를 갖고 있다.
thread는 process 내에서 실행되는 실행 흐름의 단위이다.
한 process 내에서 여러 thread를 생성이 가능하며, 같은 프로세스에 속한 thread들은 process의 자원(ex, Memory, File Descriptor)을 공유하며 독립적으로 실행될 수 있다. 이 말은 두 가지의 장점을 가진다.
- thread 간 데이터를 통신하는 데 있어 process보다 훨씬 빠르고 효율적
(process 간 통신은 IPC 등의 복잡한 매커니즘을 이용해서 통신이 가능)- 한 thread가 차단되거나 긴 작업을 수행하고 있어도 다른 thread는 계속 실행 가능
→ 추가적으로 운영체제 관점에서 thread를 Light Weight Process(LWP) 라고 표현
위의 두 그림은 chat gpt-4를 이용해서 그린 thread와 process의 차이를 돕기 위한 다이어그램이다.
thread와 process의 가장 큰 차이점은 '자원 공유'이다.
process는 운영체제에게 할당 받은 각각 독립적인 자원을 가지지만, thread는 같은 process 내에서 자원을 공유하며 이로 인해 thread 간의 데이터 공유가 훨씬 빠르고 효율적이라고 볼 수 있다.
그리고 그림에서 볼 수 있듯이, 각 process는 독립적인 힙, 전역 변수, 코드 등의 자원을 가지고 있지만, thread들은 각자의 CPU 시간과 스택을 가지고 있지만, 힙과 같은 메모리 공간은 프로세스와 공유하고 있는 걸 알 수 있다.
또한, process는 운영체제로부터 자원을 할당 받아 독립적으로 실행되는 반면, thread는 process 내에서 실행되는 흐름의 단위(보다 경량화)이다.
→ thread 생성이 process 생성보다 더 빠르고 자원을 적게 소모
지금까지 thread의 개념에 대해서 간단히 살펴봤는데 이제부턴 본격적으로 thread를 적용한 채팅 프로그램을 구현하기 위해 python에서 thread 사용법과 code 등을 위주로 살펴보자.
파이썬에서 ‘threading’ 모듈을 import 하면서 thread를 사용할 수 있는데 다양한 thread 관련 기능을 파이썬에서 제공한다.
나는 최대한 파이썬에서 제공한 라이브러리를 적극적으로 활용할 예정…!
일단 먼저 python에서 thread의 특징은 다른 언어와 차이점이 하나 존재한다. 바로 CIL(Global Interpreter Lock)라는 파이썬의 정책인데 간단히 정리한 내용은 아래 참고.
GIL은 파이썬 인터프리터가 한 번에 하나의 thread만을 실행하도록 제한하는 매커니즘으로 이는 메모리 관리를 단순화하고, CPython 인터프리터의 설계를 간단하게 만든다.
GIL로 인해 파이썬 multi-thread 프로그램은 실질적으로 동시에 여러 작업을 수행하지 못 하며, CPU 사용률이 저하된다. cpu-bound 작업은 cpu 계산이 많이 필요한 작업으로 cpu 성능에 의존하는데 CIL로 인해 thread들은 동시에 실행되지 못 하며 번갈아 실행되는 것과 유사하게 동작해 cpu-bound 작업에서 성능 저하를 초래할 수 있다.하지만, I/O bound 관점에서는 이야기가 다르다. I/O bound 작업은 네트워크 요청, disk write and read 등 cpu가 계산을 하지 않고 다른 메모리나 장치(I/O)에서 작업을 하는 것으로 cpu는 block 상태가 되는데 이 block 상태를 기다리는 동안 다른 thread가 실행될 수 있다.
→ 채팅 프로그램은 네트워크 작업이 많은 프로그램으로 multi-thread가 적합!!
아래 코드는 thread 사용하지 않고 1초 쉬고 print를 호출하는 프로그램과 thraeding 모듈을 이용해 구현한 프로그램의 설명으로 각 차이점을 위주로 보면 이해하기 쉬울 수도…
import time
def do_someting():
print("1초 잘랭")
time.sleep(1)
print("다시 깨어남")
start = time.perf_counter()
for _ in range(10):
do_someting()
finish = time.perf_counter()
print(f'총 걸린 시간: {finish - start} 초')
import time
import threading
def do_someting():
print("1초 잘랭")
time.sleep(1)
print("다시 깨어남")
threads = []
start = time.perf_counter()
for _ in range(10):
thread = threading.Thread(target=do_someting)
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
finish = time.perf_counter()
print(f'총 걸린 시간: {finish - start} 초')
아래부터 본격적으로 python으로 구현한 Multi-Thread 채팅 프로그램의 코드이다. 채팅 프로그램은 서버와 클라이언트 부분으로 구성되며, server와 client 프로그램의 코드를 리뷰하고 실행 결과를 중심으로 설명하겠습니당~
💡 저번 단일 thread 채팅 프로그램과 차이점을 위주로~~def client_accept(client_size):
child_sock, child_addr = server_sock.accept()
client_size += 1
client_sockets.append([child_sock, child_addr])
print(f'{child_addr}에서 접속')
return client_size
client_accept
함수는 새로운 클라이언트의 접속을 받아들이고 대기한다.def send_msg_all(msg, from_sock):
for client_socket in client_sockets:
if not client_socket == from_sock:
client_socket[0].sendall(msg)
send_msg_all
함수는 받은 메시지를 모든 클라이언트에게 전송한다.def recv_msg(from_sock):
while True:
message = from_sock[0].recv(1024)
send_msg_all(message, from_sock)
print(f'client({from_sock[1]}): {message.decode()}')
recv_msg
함수는 클라이언트로부터 메시지를 받아서 send_msg_all
함수를 호출하여 모든 클라이언트에게 메시지를 전송한다.threads = []
threads_size = 0
client_sockets = []
client_size = 0
client_sockets
리스트는 접속한 클라이언트의 소켓과 주소를 저장한다.threads
리스트는 클라이언트별로 생성한 스레드를 저장한다.while True:
client_size = client_accept(client_size)
print(f'현재 접속자 수: {client_size}')
recv_thread = threading.Thread(target=recv_msg, args=(client_sockets[client_size - 1],))
recv_thread.start()
threads.append(recv_thread)
client_accept
함수를 호출하여 클라이언트의 소켓과 주소를 저장한다recv_msg
함수를 실행하는 새로운 thread를 생성 후 생성한 thread는 threads
리스트에 추가된다.def recv_msg():
while True:
msg = client_sock.recv(1024)
print(f'Server: {msg.decode()}\n>>> ', end='')
recv_msg
함수는 서버로부터 메시지를 받아 msg 변수에 저장해 클라이언트 터미널에 출력한다.def send_msg(msg):
client_sock.sendall(msg.encode('utf-8'))
send_msg
함수는 사용자가 입력한 메시지를 서버에게 전송한다.sendall
메소드를 사용하여 메시지의 모든 바이트가 전송될 때까지 전송을 시도한다.recv_thread = threading.Thread(target=recv_msg)
recv_thread.start()
이렇게 multi-thread를 사용하여 채팅 프로그램을 구현하면, 단일 thread 프로그램과 비교하여 다음과 같은 장점이 있다:
지금까지 thread를 적용해 멀티스레드 채팅 프로그램을 구현하는 과정을 posting 하는 과정에서 thread의 이해와 여러가지 등을 배울 수 있었다. 다음 post에서는 이 프로그램에 추가적인 기능과 유저 기능을 추가하겠습니당~~~
https://velog.io/@aeong98/운영체제OS-프로세스와-스레드
[분산 시스템] 2-3. CPU Bound & I/O Bound
[병렬 프로그래밍] 1. Multi-Thread 사용하기 with Python
원본 코드server.py
import socket
import threading
def client_accept(client_size):
child_sock, child_addr = server_sock.accept()
client_size += 1
client_sockets.append([child_sock, child_addr])
print(f'{child_addr}에서 접속')
return client_size
def send_msg_all(msg, from_sock): # for 문을 통해 접속된 client 모두에게 같은 msg 전송
for client_socket in client_sockets:
if not client_socket == from_sock: # 메시지를 보낸 클라이언트에게는 다시 보내지 않음
client_socket[0].sendall(msg)
def recv_msg(from_sock):
while True:
message = from_sock[0].recv(1024)
send_msg_all(message, from_sock)
print(f'client({from_sock[1]}): {message.decode()}')
threads = []
threads_size = 0
client_sockets = []
client_size = 0
host = 'localhost'
port = 55555
server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP)
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_sock.bind((host, port))
server_sock.listen(5)
print(f'채팅 서버 open\nhost: {host}\tport: {port}\n---------------------------------------')
while True:
client_size = client_accept(client_size) # 만약 연결된 클라이언트가 있으면 다음 코드로 넘어감
print(f'현재 접속자 수: {client_size}')
recv_thread = threading.Thread(target=recv_msg, args=(client_sockets[client_size - 1],))
recv_thread.start()
threads.append(recv_thread)
# child_sock.close()
# parent_sock.close()
client.py
import socket
import threading
def recv_msg():
while True:
msg = client_sock.recv(1024)
print(f'Server: {msg.decode()}\n>>> ', end='')
def send_msg(msg):
client_sock.sendall(msg.encode('utf-8')) # send vs sendaall: sendall이 버퍼에 있는 데이터를 다 보냈음을 보장함!!
server_host = 'localhost'
server_port = 55555
client_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
#서버와 연결
client_sock.connect((server_host, server_port))
print(f'Server: {server_host}, {server_port}와 정상적으로 연결')
recv_thread = threading.Thread(target=recv_msg)
recv_thread.start()
while True:
msg = input(">>> ")
send_msg(msg)
client_sock.close()