[Python] 쓰레드(Thread)과 lock 그리고 데드락


Study/Python  2020. 1. 6. 08:12

안녕하세요. 명월입니다.


이 글은 Python에서 쓰레드(Thread)과 lock 그리고 데드락에 관한 글입니다.


쓰레드(Thread)란 프로세스 안에서 움직이는 가장 최소 단위를 쓰레드라고 합니다. 이렇게 표현하면 무슨 말인지 어렵습니다.

먼저 프로세스라는 말은 메모리에 할당되서 있는 한 개의 프로그램을 의미합니다. 프로그램 안에서 여러 개의 프로세스를 운영할 수가 없기 때문에 프로그램이 하나의 프로세스라는 개념입니다.

그럼 한 개의 프로세스는 여러 개의 쓰레드를 가질 수 있는데 쉽게 이야기 하면 동시에 움직이는 병렬 처리 기법이라고 생각하면 됩니다.

def example():
  for i in range(1,10,1):
    print(i);

example();
example();

위 예제를 보면 example 함수를 두번 호출했는데 example 함수 안에는 1부터 10까지의 반복 루프를 처리하게 되었습니다.

당연히 처음 example함수가 끝나고 두번째 example함수를 호출하기 때문에 순서대로 나오는 것을 확인할 수 있습니다.


그러나 이 두 함수를 동시에 실행하고 싶습니다.

첫번째 example함수가 끝나기 전에 두번째 example함수를 호출하고 싶은 것입니다.

# 쓰레드를 사용하기 위해서는 threading 모듈을 import해야 한다.
import threading;

def example():
  for i in range(1,10,1):
    print(i);
# target에 함수 명을 넣고 start를 한다.
threading.Thread(target=example).start();
threading.Thread(target=example).start();

결과를 보시면 6정도에 7이 나오지 않고 1이 나왔습니다. 이는 7은 2뒤에 있네요. 보시면 example를 두 번 호출했지만 첫번째 호출한 example이 끝나기도 전에 두번째 example이 호출되는 것을 확인할 수 있습니다.


이렇게 사용하면 Thread의 활용도를 확인하기 어렵습니다.

이번에는 조금 다른 예제로 Queue를 사용해서 여러 작업을 동시에 처리하는 프로그램을 만들겠습니다.

# 쓰레드를 사용하기 위해서는 threading 모듈을 import해야 한다.
# queue를 사용하기 위해서 queue를 import해야 한다.
# time 모듈은 Thread 상에서 처리를 잠시 멈추게 하는 기능이 있다.
import threading, queue, time;

# work로 Queue를 생성헀다.
# Queue란 Fifo의 구조의 자료형으로 put으로 넣은 데이터 순서대로 get을 통해 데이터가 나오는 구조체이다.
work = queue.Queue();
# generator함수에서 queue에 1부터 10까지 데이터를 넣었다.
def generator(start, end):
  for i in range(start,end,1):
    work.put(i);
# 출력은 queue가 빌때까지 루프를 통해서 데이터를 취득한다.
def display():
  while work.empty() is False:
    data = work.get();
    print("data is " + str(data));
    # 1초 단위로 루프를 멈춘다.
    time.sleep(1);
    work.task_done();

# 두개의 쓰레드에 두개이 처리를 넣었다.
# 쓰레드에서 함수를 호출할 때 파라미터는 args로 넣을 수 있다.
threading.Thread(target=generator, args = (1,10)).start();
threading.Thread(target=display).start();
work.join();

위에서 queue에 대해 task_done과 join함수를 사용했습니다. queue에 대한 자세한 설명은 아래 도큐멘트에서 확인 가능합니다.

링크 - https://docs.python.org/ko/3/library/asyncio-queue.html


join은 일단 처리 대기를 나타내는 건데 work 큐가 빌 때까지 멈추는 형태입니다.

쓰래드에는 lock의 기능이 있습니다. 이 한개의 리스트에 여러 쓰레드에서 무차별적으로 데이터를 넣어버리면 데이터가 없어지는 경우가 있습니다.

import threading, queue, time;

data = 0;
def generator(start, end):
  global data;
  for i in range(start, end, 1):
    buf = data;
    time.sleep(0.01);
    # data 값을 1씩 증가
    data = buf + 1;

# generator함수를 두개의 쓰레드로 실행했다.
t1 = threading.Thread(target=generator, args = (1,10));
t2 = threading.Thread(target=generator, args = (1,10));
# 쓰레드 시작
t1.start();
t2.start();
# 쓰레드가 종료할 때까지 대기
t1.join();
t2.join();

print(data);

분명히 generator 함수를 두번 호출했고 9번씩 두번이 돌아야 하는데 결과는 9라는 값이 나왔습니다.

이유는 제가 일부러 에러를 만들기 위해서 만든 것입니다만, 첫번째 쓰레드에서 buf에 데이터를 담고 두번째 쓰레드에서 또 buf에 데이터를 담습니다.

그런수 data에는 다시 buf의 1 증가 값을 넣기 때문에 이런 현상이 나옵니다.


이걸 해결하기 위해서는 lock을 사용해서 동기화를 해야 합니다.

import threading, queue, time;

data = 0;
# 쓰레드의 Lock를 가져온다.
lock = threading.Lock();
def generator(start, end):
  global data;
  for i in range(start, end, 1):
    # lock이 설정된 이상 다음 이 lock를 호출할 때 쓰레드는 대기를 한다.
    lock.acquire();
    buf = data;
    time.sleep(0.01);
    # data 값을 1씩 증가
    data = buf + 1;
    # 사용이 끝나면 lock 해제한다.
    lock.release();

# generator함수를 두개의 쓰레드로 실행했다.
t1 = threading.Thread(target=generator, args = (1,10));
t2 = threading.Thread(target=generator, args = (1,10));
# 쓰레드 시작
t1.start();
t2.start();
# 쓰레드가 종료할 때까지 대기
t1.join();
t2.join();

print(data);

원하는 결과값이 나왔습니다.


lock 사용할 때는 항상 데드 락을 조심해야 합니다. 데드 락이란 서로 간의 락이 묶여있는 상태를 말합니다.

import threading, queue, time;

data = 0;
lock1 = threading.Lock();
lock2 = threading.Lock();
def generator1(start, end):
  global data;
  for i in range(start, end, 1):
    # lock1로 lock 걸었다. (여기 이후를 실행하려면 lock1이 release상태여야한다.)
    lock1.acquire();
    # lock2로 lock 걸었다. (여기 이후를 실행하려면 lock2이 release상태여야한다.)
    lock2.acquire();
    print(i);
    time.sleep(0.1);
    lock2.release();
    lock1.release();
    
def generator2(start, end):
  global data;
  for i in range(start, end, 1):
    # lock2로 lock 걸었다. (여기 이후를 실행하려면 lock2이 release상태여야한다.)
    lock2.acquire();
    # lock1로 lock 걸었다. (여기 이후를 실행하려면 lock1이 release상태여야한다.)
    lock1.acquire();
    print(i);
    time.sleep(0.1);
    lock1.release();
    lock2.release();

# generator1함수를 쓰레드로 실행
t1 = threading.Thread(target=generator1, args = (1,10));
# generator2함수를 쓰레드로 실행
t2 = threading.Thread(target=generator2, args = (1,10));
# 쓰레드 시작
t1.start();
t2.start();
# 쓰레드가 종료할 때까지 대기
t1.join();
t2.join();

print(data);

위가 데드락의 상황인데 쓰레드는 generator1과 generator2에서는 서로 역으로 lock 걸려있는 상황입니다.

즉, generator1에서 lock1과 lock2를 걸고 들어갑니다. generator2에서 lock2와 lock1를 걸고 들어갑니다.

generator1에서 lock1에 락을 걸고 lock2를 걸고 들어가려니 이미 lock2이 generator2에서 걸린 상태입니다.

generator2에서 lock2에 락을 걸고 lock1로 걸고 들어가려니 이미 lock1이 generator1에서 걸린 상태입니다.


즉,서로 generator1과 generator2가 서로 lock이 풀리기를 기다리는 상태가 되어 버렸습니다.

이게 데드락입니다. 위 예제는 제가 데드락을 표현하기 위해서 억지로 만든 예제입니다만, 실무에서는 함수 안에 함수 안에서 lock을 걸고 다른 곳에서 또 lock이 걸린 상태가 되어 버리면 이런 데드락 상태가 되어버립니다.


해결 방법은 개발시에 되도록 lock을 하나로 통일하고 중첩 lock이 되지 않도록 하는 방법 밖에 없네요.


여기까지 Python에서 쓰레드(Thread)과 lock 그리고 데드락에 관한 설명이었습니다.


궁금한 점이나 잘못된 점이 있으면 댓글 부탁드립니다.