atoi 함수 구현하기

윤효준·2024년 8월 8일

42 Libft 복습

목록 보기
19/28

atoi 함수의 manual은 다음과 같다!

Synopsis

#include <stdlib.h>

int	atoi(const char *str);

Description

  • atoi 함수는 문자열의 처음부분을 정수로 변환한다.

Implementation notes

  • atoi 함수는 thread-safe, asyn-cancel-safe 함수이다.

여기서 잠깐!!! thread-safe와 asyn-cancel-safe란???

thread-safe

Thread-safe(스레드 안전성)은 멀티스레드 환경에서 여러 스레드가 동시에 특정 함수나 데이터를 사용할 때도 그 함수나 데이터가 올바르게 동작하도록 보장되는 것을 의미한다. 스레드 안전하지 않은 함수나 데이터는 여러 스레드가 동시에 접근하면 예상치 못한 동작이나 버그를 일으킬 수 있다.
아래 예시를 보자

#include <stdio.h>
#include <pthread.h>

int counter = 0; //전역 변수 선언

void* increment_counter(void* arg) {
    for (int i = 0; i < 100000; i++) {
        counter++; //함수가 전역 변수의 값을 변경함
    }
    return NULL;
}

int main() {
    pthread_t thread1, thread2; //thread 선언

	//thread1이 increment_counter 함수를 실행하도록 함
    pthread_create(&thread1, NULL, increment_counter, NULL);
    pthread_create(&thread2, NULL, increment_counter, NULL);

	// 두 스레드를 합침
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);

    printf("Final counter value: %d\n", counter);
    return 0;
}

200000을 예상하셨겠지만 의도대로 200000이 나오지가 않는다.

왜 와이??? 컴퓨터가 변수를 읽고 쓰는 방식과 관련이 있는데 아래의 예시를 보자!

  1. Thread1이 counter 값을 읽어 온다.

  2. Thread2가 counter 값을 읽어 온다.
    여기서 읽어 온 값은 Thread1과 Thread2이 동일하다.

  3. Thread1이 값을 1 증가시킨다.

  4. Thread2가 값을 1 증가시킨다.

  5. Thread1이 값을 메모리에 쓴다.

  6. Thread2가 값을 메모리에 쓴다.

그럼 counter를 저장하는 메모리에 저장된 값은 최종적으로 Thread2가 쓴 값이 되므로 2번 더해진 것이 아닌 1번만 더해진 값인 것이다.

그럼 thread-safe하기 위해서는 어떻게 해야 할까??

Thread1이 counter을 수정하려고 할 때 다른 Thread는 counter에 접근하지 못하도록 철벽을 치는거다.
이 개념을 mutex라고 한다.

#include <stdio.h>
#include <pthread.h>

int counter = 0;
pthread_mutex_t lock;

void* increment_counter(void* arg) {
    for (int i = 0; i < 100000; i++) {
        pthread_mutex_lock(&lock);  // 뮤텍스 잠금
        counter++;  // 안전하게 접근
        pthread_mutex_unlock(&lock);  // 뮤텍스 잠금 해제
    }
    return NULL;
}

int main() {
    pthread_t thread1, thread2;

    pthread_mutex_init(&lock, NULL);  // 뮤텍스 초기화

    pthread_create(&thread1, NULL, increment_counter, NULL);
    pthread_create(&thread2, NULL, increment_counter, NULL);

    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);

    printf("Final counter value: %d\n", counter);

    pthread_mutex_destroy(&lock);  // 뮤텍스 파괴
    return 0;
}

여기서 mutex와 비슷하게 구현해볼 수도 있다.

#include <stdio.h>
#include <stdbool.h> // bool 자료형 사용을 위해 include
#include <pthread.h>

int		counter = 0;
bool	my_mutex = false;

void	unlock(void)
{
	my_mutex = false;
}

void	lock(void)
{
	// 다른 thread가 사용중일 때 while 문을 돌면서 확인
    // 이를 busy wait이라고 한다.
    // 일상생활에 비유하자면 화장실 문이 열려 있는지 계속 확인하고 있는 것이다.
	while (my_mutex == true)
    	;
    my_mutex = true;
}

void* increment_counter(void* arg) {
    for (int i = 0; i < 100000; i++) {
        lock();
        counter++;  // 안전하게 접근
        unlock();
    }
    return NULL;
}

int main() {
    pthread_t thread1, thread2;

    pthread_create(&thread1, NULL, increment_counter, NULL);
    pthread_create(&thread2, NULL, increment_counter, NULL);

    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);

    printf("Final counter value: %d\n", counter);

    return 0;
}

하지만 여기서도 my_mutex를 동시에 접근할 수 있기 때문에 잘 작동한다고 볼 수는 없다.

그럼 mutex는 동시에 접근할 수 없는건가라고 생각할 수 있는데 그렇다!
잠금과 해제가 원자적으로 수행된다.

여기서 원자적이란 작업이 하나의 단위로 취급되는 것이다.
위의 예시에서 counter를 읽고 1 증가시키고 메모리에 작성하는 것을 하나의 단위로 본다고 생각하면 된다.

그럼 원자성은 어떻게 구현하냐????
기본적인 아이디어는 한 번의 cpu 사이클 내에서 연산을 다 완료하도록 하여 중간에 다른 스레드나 프로세스가 끼어들 수 없게 하는 것이다!

asyn-cancel-safe

비동기 취소는 하나의 스레드가 다른 스레드를 강제로 종료시키는 기능을 말한다. 예를 들어, pthread_cancel() 함수를 사용해 다른 스레드를 취소할 수 있다. 문제는 이 취소가 어떤 코드에서든지 발생할 수 있다는 점이니다. 코드가 특정 작업을 수행하던 중에 스레드가 취소되면, 해당 작업이 불완전하게 남거나 데이터가 손상될 수 있다.

아래 예시를 보자

#include <stdio.h>
#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>

void* critical_section(void* arg) {
    FILE *file = fopen("example.txt", "w");
    if (file == NULL) {
        perror("Failed to open file");
        return NULL;
    }

    // 파일에 데이터를 쓰기 시작
    fprintf(file, "This is a critical section\n");

    // 5초 사이는 스레드가 취소될 수 있는 시점
    sleep(5);  // 5초 동안 대기

    fprintf(file, "This is the end of the critical section\n");
    fclose(file);

    return NULL;
}

int main() {
    pthread_t thread;

    // 스레드를 생성
    pthread_create(&thread, NULL, critical_section, NULL);

    // 2초 후에 스레드를 취소
    sleep(2);
    pthread_cancel(thread);

    // 스레드가 종료되기를 기다린다.
    pthread_join(thread, NULL);

    return 0;
}

이는 함수가 파일을 열고 데이터를 쓰다가 호출이 취소되어 파일이 닫히지 않고 그에 따라 파일의 데이터도 손상이 될 수 있다. 근데 여기서 의문이 들수도 있다.

비동기 취소는 하나의 Thread가 다른 Thread를 강제 종료시키는 건데 Thread가 하나 밖에 없잖아요????

main Thread가 있기에 Thread가 하나만 있는 것은 아니다!

이러한 비동기 취소에서 안전하려면 간단하다.
취소를 못 시키게 하면 된다.

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

void* critical_section(void* arg) {
    FILE *file = fopen("example.txt", "w");
    if (file == NULL) {
        perror("Failed to open file");
        return NULL;
    }

    // 비동기 취소를 비활성화
    pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);

    // 파일에 데이터를 씁니다.
    fprintf(file, "This is a critical section\n");

    // 스레드가 취소될 수 있는 시점입니다.
    sleep(5);  // 예시를 위해 5초 동안 대기

    fprintf(file, "This is the end of the critical section\n");

    // 파일을 닫고, 리소스를 해제합니다.
    fclose(file);

    // 비동기 취소를 다시 활성화
    pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL);

    return NULL;
}

int main() {
    pthread_t thread;

    // 스레드를 생성합니다.
    pthread_create(&thread, NULL, critical_section, NULL);

    // 2초 후에 스레드를 취소합니다.
    sleep(2);
    pthread_cancel(thread);

    // 스레드가 종료되기를 기다립니다.
    pthread_join(thread, NULL);

    return 0;
}

Thread-unsafe의 영향

  1. 데이터 경합(Race Condition)

    • 여러 스레드가 동시에 동일한 자원(예: 전역 변수)에 접근하면, 데이터가 엉뚱한 값으로 변경될 수 있다. 예를 들어, 두 스레드가 동시에 변수를 증가시키면, 실제로는 하나만 증가한 것처럼 보일 수 있다.
  2. 데이터 일관성 손상

    • 스레드가 자원을 동기화하지 않고 공유하면, 데이터의 일관성이 손상될 수 있다. 예를 들어, 한 스레드가 데이터 구조를 수정하는 동안 다른 스레드가 그 데이터를 읽으면, 읽은 데이터가 일관성이 없는 상태일 수 있다.
  3. 예측 불가능한 동작

    • 여러 스레드가 동시에 같은 코드를 실행하면, 코드가 실행되는 순서나 결과가 예측 불가능해질 수 있다. 이는 디버깅을 어렵게 만들고, 시스템의 안정성을 떨어뜨릴 수 있다.
  4. 교착 상태(Deadlock)

    • 여러 스레드가 서로 다른 순서로 자원을 요청할 때, 교착 상태가 발생할 수 있다. 이는 스레드들이 영원히 서로를 기다리며 멈추는 상태로 이어질 수 있다.

Async-cancel-unsafe의 영향

  1. 자원 누수(Resource Leak)

    • 함수가 중간에 취소되면서 파일, 메모리, 뮤텍스 등 자원이 제대로 해제되지 않을 수 있다. 이로 인해 시스템 자원이 고갈되거나, 장기적으로 시스템의 안정성이 저하될 수 있다.
  2. 데이터 일관성 손상

    • 파일에 데이터를 쓰는 도중 취소가 발생하면, 파일이 부분적으로만 업데이트되거나 손상될 수 있다. 데이터베이스 트랜잭션 같은 작업에서도 취소가 발생하면, 데이터 일관성이 깨질 수 있다.
  3. 교착 상태(Deadlock)

    • 취소가 발생하면서 자원을 해제하지 않으면, 다른 스레드가 해당 자원에 접근할 수 없게 되어 교착 상태가 발생할 수 있다. 예를 들어, 뮤텍스를 잠근 상태에서 취소되면, 다른 스레드가 영원히 뮤텍스가 해제되기를 기다리게 된다.
  4. 불완전한 작업 종료

    • 중요한 작업이 완료되지 않은 상태에서 중단되므로, 시스템 상태가 불완전하게 남을 수 있다. 이는 이후 실행되는 코드나 시스템 동작에 영향을 미칠 수 있다.

그럼 왜 atoi 함수가 thread-safe하고 async-cancel-safe한지 알아보자!

const char *str을 인수로 받기에 공유 자원을 수정하지 않는다.
atoi는 내부적으로 자원 할당을 하지 않아 자원 누수가 발생하지 않는다.

참고로 printf는 일반적으로 thread-unsafe함수다.
그래서 multi-thread 환경에서는 mutex로 막아 놓고 사용하는 것이 좋다.
현대에 들어서는 thread-safe하다고는 하지만 mutex로 막지 않으면 출력이 섞일 수 있어 막아 놓는 것이 좋다!

Return Values (IBM 문서 atoi 설명 링크)

  • atoi함수는 입력 문자를 숫자로 해석하여 생성되는 int 값을 리턴한다.
  • 함수가 입력을 해당 유형의 값으로 변환할 수 없는 경우 리턴값은 0입니다.
  • 리턴값은 오버플로의 경우 정의되지 않습니다.

구현

static int	ft_isspace(char c)
{
	return ((9 <= c && c <= 13) || c == ' ');
}

static int	ft_isdigit(char c)
{
	return ('0' <= c && c <= '9');
}

int	ft_atoi(const char *str)
{
	long	ans;
	int		sign;

	ans = 0;
	sign = 1;
	while (ft_isspace(*str))
		str++;
	if (*str == '-' || *str == '+')
	{
		if (*str == '-')
			sign = -1;
		str++;
	}
	while (ft_isdigit(*str))
	{
		ans = 10 * ans + (*str - '0');
		str++;
	}
	return ((int) sign * ans);
}

여기서 atoi의 반환형은 int인데 ans를 long으로 선언한 이유가 궁금할 것이다. 오버플로우에 대한 atoi의 리턴값을 확인해보면 이해가 될 것이다.

아래 코드를 한 번 실행해보자!

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
	printf("%d %ld\n", atoi("2147483648"), atoi("2147483648"));
	printf("%d %ld", atoi("9223372036854775809"),atoi("9223372036854775809"));
	return (0);
}

아래 사진은 위 코드를 실행한 결과이다.

2147483648은 MAX_INT보다 1큰 값이고 9223372036854775809은 MAX_LONG보다 1 큰 값이다.

일단 첫번째 줄을 보자!
-2147483648과 2147483648이 나왔다. 왜 일까??
이는 자료형을 읽어 올 때와 상관이 있다.

32비트 정수 최대값: 2147483647 (01111111 11111111 11111111 11111111)
1을 더하면: 2147483648 (10000000 00000000 00000000 00000000)
1 더한 값을 32비트 정수로 읽어오면 -2147483648이 된다.
대신 64비트 정수로 읽어오면 2147483648이다.

밑에 -1 9223372036854775807도 동일하다.

조금 더 확신을 얻기위해서는 "-2147483649"도 돌려보고 하면 atoi는 오버플로우에 대해 비트 연산에 충실하게 대응한다. 이와 동일하게 구현하기 위해서는 ans를 int가 아니라 long으로 선언을 해야 한다.

profile
작은 문제를 하나하나 해결하며, 누군가의 하루에 선물이 되는 코드를 작성해 갑니다.

0개의 댓글