Conncurrent Programming
- 여러 개의 Concurrent flow(process, thread, handler 등)이 동시에 실행되는 환경에서 작업을 수행하는 프로그래밍 패러다임.
- Concurrent Prgoramming은 병렬처리, 비동기 작업, 이벤트 기반 프로그래밍 등 다양한 시나리오에서 사용된다.
- 일반적인 순차적인 프로그래밍과는 달리 다른 문제점들이 있다.
- Race condition: Concurrent flow들이 공유 데이터에 동시에 접근하고 수정할 때 발생. 경쟁 상태는 임의의 스케줄링 결정에 의존하니까 예측하기가 어렵다.
- Deadlock: Deadlock은 둘 이상의 Concurrent flow가 서로가 점유하고 있는 자원을 요청하며 무한히 대기하는 상태임.
- Livelock / starvation / fairness
Iterative servers
Iterative servers는 한 번에 하나의 요청을 처리하는 방식이다. 클라이언트의 요청을 받으면 해당 요청을 처리한 후 다음 요청을 처리한다. Iterative servers는 요청을 순차적으로 처리하기 때문에 한 번에 하나의 클라이언트만 서비스한다.
두번째 클라이언트가 Iterative server에 연결을 시도할 때 발생하는 상황을 보면, 두 번쨰 클라이언트가 connect 함수를 호출하여 서버에 연결을 요청한다. connect 함수는 연결이 아직 수락되지 않았더라도 호출이 즉시 반환된다. 이는 서버측 TCP관리자가 요청을 대기열에 넣기 떄문인데, 이를 TCP listen backlog라고 한다.
두번째 클라이언트가 rio_written 함수를 호출해서 데이터를 전송하면, 서버 측 TCP 관리자는 input data를 버퍼링한다. 그리고 두 번쨰 클라이언트가 rio_readlibeb 함수를 호출해서 데이터를 읽으려고 할 때, 서버는 아직 읽을 데이터를 쓰지 않았기에 호출이 block된다.
이러한 문제를 해결하기 위해서 Concurrent servers를 사용하는 것이다. 동시성 서버는 여러개의 Concurrent한 흐름을 사용해서 동시에 여러 클라이언트들에게 서비스를 할 수 있는 방식이다. Concurrent server는 여러개의 동시 Concurrent flow을 생성하고, 각 클라이언트 요청을 병렬적으로 처리해서 동시에 여러 클라이언트에게 서비스한다. 이를 통해서 클라이언트들은 서로 대기하지 않고 독립적으로 요청을 처리한다.
Approach for Writing Concurrent Servers: Process-based, Event-based, Thread-based
- Proocess-based Approach
프로세스 기반의 접근법은 각 클라이언트를 처리하기 위해 별도의 프로세스를 생서하는 방식이다. OS의 kernel이 자동으로 여러 논리적 흐름을 교차로 처리하며, 각 흐름은 독립된 주소 공간을 가진다. 이 방식의 장점은 각 프로세스가 독립적으로 실행되어 다른 프로세스의 오류로부터 안전하다는 점이 있다. 단점으로는 프로세스간의 상태 공유가 어렵고, 프로세스 생성과 스위칭에 비용이 많이 들 수 있다는 점이다. fork()와 exec()와 같은 시스템 콜을 이용한다. - Event-based Approach
Event-based Approach는 프로그래머가 직접 여러 논리적 흐름을 교차로 처리하는 방식이다. 이 방식은 모든 flow가 같은 주소 공간을 공유하면서 I/O Multiplexing이라는 기술을 사용한다. 이 방식의 장점은 시스템 자원을 효율적으로 사용하고, 대량의 동시 연결을 처리할 수 있다는 점이다. 단점은 구현이 복잡하고 디버깅이 쉽지 않다. select(), poll(), epoll()과 같은 I/O Multiplexing function이 이용된다 - Thread-based Approach
Thread-based Approach는 프로세스 기반과 이벤트 기반의 하이브리드라고 볼 수 있다. 이 방식에서는 kernel이 여러 logical flow를 자동으로 교차 처리하고, 각 flow가 같은 주소 공간을 공유한다. thread는 프로세스 내에서 실행되는 독립적인 실행 flow로, 각 thread는 독립된 stack을 가지고, 나머지 메모리 공간은 동일 프로세스 내의 다른 thread와 공유한다. 이 방식의 장점은 process 기반 접근보다 시스템 자원을 덜 사용하고, 같은 프로세스 내의 thread 간에는 메모리를 공유할 수 있다는 점이다. 닩머으로는 thread간의 동기화가 필요하고, 하나의 thread에서 발생한 오류가 전체 프로세스 영향을 미칠 수 있다. pthread_create()와 같은 함수를 사용한다.
Threads Memory model: Conceptual and Actual
개념적으로 Thread Memory model을 보면, 한 프로세스 내에서 여러 Thread가 실행된다. 각 Thread는 자신만의 Thread context를 가지고 있고, 이는 thread Id, stack, stack pointer, PC, condition code, GP registers를 포함한다. 모든 Thread는 나머지 process context를 공유한다. code, data, heap virtual memory공간의 shared library segment, open file, handler 등이 포함된다.
실제 Thread memory model에서는 데이터의 분리가 엄격하게 되어 있지 않다. 레지스터 값은 실제로 분리되어 보호되지만, 어떤 thread든 다른 thread의 stack을 읽고 쓸 수 있다. 이런 부분이 오류의 원인이 될 수 있다.
Three Ways to Pass Thread Argument
Thread 인수를 전달하는 것은 새로 생성된 Thread에 필요한 데이터나 상태를 제공하는 것이다.
Thread가 네트워크에서 오는 다양한 연결을 처리해야 하는 서버에서 사용될 수 있다. 각 Thread에는 연결에 대한 정보(ex: socket discriptor)가 필요하고, 이 정보는 thread 함수에 인수로 전달된다.
즉, 이러한 Argument(인수)는 Thread가 작업을 시작하는 데 필요한 초기 상태를 제공하는 매우 중요한 역할을 한다.
인수를 전달하는데는 여러가지 방법이 있다.
- Malloc / free: 공간을 할당하고, 이를 포인터로 pthread_create에 전달한다. consumer은 이 포인터를 역참조하고, 공간을 해제한다. 이 방법은 항상 작동하고, 대량 데이터를 전달할 때 적합하다.
- Cast of int: 정수나 long을 void로 캐스트하고 이를 pthread_create에 전달한다. consumer는 인수를 다시 int/long으로 cast한다. 이 방법은 작은 양의 데이터를 전달하는 데 적합하다.
- Wrong method!!: producer는 pthread_create에 자신의 stack address를 전달한다. consumer는 이 포인터를 역참조한다. 근데 이 방법은 안전한 방법이 아니다. 왜냐하면 호출된 thread의 stack이 회수되면 포인터는 무효화되고 이로 인해서 예기치 않은 결과를 초래할 수 있기 때문이다.
다음의 예시, Case들을 보면서 이해해보자.
Case 1은 메모리를 malloc하거나 free하는 대신 배열 hist에서 각 thread에 대한 위치를 전달한다. main함수에서 thread를 생성하고, 각 thread에 hist 배열의 각 원소에 대한 포인터를 전달한다. 이렇게 하면 각 thread는 해당 포인터를 사용해서 hist 배열의 해당 원소를 직접 수정할 수 있다.
Pthread_create(&tids[i], NULL, thread, &hist[i]);
그런 다음 thread 함수 thread에서는 이 포인터를 int 포인터로 casting한 다음, 포인터가 가리키는 값을 증가시킨다.
*(int *)vargp += 1;
마지막으로 check함수는 hist 배열을 확인하고 각 우너소가 정확히 1이 되었는지 확인한다.
이러한 방식의 문제점은 hist배열이 공유 메모리에 위치하며, 모든 thread가 동시에 이 배열에 접근할 수 있다는 것이다. 그러나 이 코드에서는 문제가 발생하지는 않는다. 왜냐하면 각 thread가 배열의 고유한 원소에만 접근하기 떄문이다. 하지만 만약 여러 thread가 배열의 동일한 원소에 동시에 접근하려고 시도한다면, race condition이 발생할 수 있다. 이를 방지하기 위해 추후에 언급할 mutex와 같은 synchronization mechanism이 필요하다.
이 코드는 Thread에 Cast of int방식을 사용해서 인수를 전달한다. main함수에서 thread를 생성하고, 각 thread에게 배열 hist의 index를 전달한다. index는 void*로 cast되어서 pthread_create 함수에 전달 된다.
Pthread_create(&tids[i], NULL, thread, (void *)i);
그런 다음 Thread에서는 이 pointer를 long으로 casting한 다음, 해당 index를 사용해서 hist 배열을 수정한다
hist[(long)vargp] += 1;
마지막으로 check 함수는 hist 배열을 확인하고, 각 원소가 정확히 1이 되었는지 확인한다.
이 코드는 thread에 인수를 malloc/free방식으로 전달한다. 동적 메모리 할당을 사용해서 각 thread에게 전달될 데이터를 저장한다. main 함수에서 thread를 생성하고, 각 thread에게 hist 배열의 index를 전달한다. 이번에는 index를 동적할당 메모리에 저장하고, 해당 메모리의 주소를 pthread_create함수에 전달한다.
long* p = Malloc(sizeof(long));
*p = i;
Pthread_create(&tids[i], NULL, thread, p);
그런 다음 thread에서는 이 포인터를 사용해서 할당된 메모리에 접근하고, 해당 index를 사용해서 hist 배열을 수정한다. 이후 할당된 메모리를 해제한다.
hist[*(long *)vargp] += 1;
free(vargp);
이 방식의 장점은 구조체와 같은 큰 데이터 세트를 thread에게 전달할 수 있다. 메모리관리에 주의하면 된다.
인수를 전달하는 잘못된 방법이다. main함수의 지역 변수 i의 주소를 각 thread에게 전달하려고 한다.
Pthread_create(&tids[i], NULL, thread, &i);
그런 다음 thread에서 이 포인터를 사용해서 hist배열을 수정하려고 시도한다.
hist[*(long *)vargp] += 1;
문제는 모든 thread가 main함수의 i변수에 대한 같은 포인터를 받는 것이다. 이로인해 data race condition이 발생한다. 즉, 여러 thread가 동시에 i변수에 접근하려고 시도하고, i 변수의 값이 thread 생성 시점에 따라 달라지기 때문에 예측 불가능한 결과를 초래한다.
또한 main함수의 i변수는 local variable로서, 함수가 종료되면 스택에서 제거되어 thread가 실행되는 동안 이 변수에 대한 참조가 유효하지 않을 가능성이 있다. 따라서 이로 인해 비정상적인 동작이 발생할 수 있다. 따라서 각 thread가 받는 데이터가 서로 독립적이어야한다는 것이다. 이를 위해서 동적메모리 할당을 사용하고, thread에게 전달할 데이터를 담은 별도의 변수를 사용해야만 한다.