프로세서의 발전 속도가 더뎌지면서, 단일 칩의 성능 향상보다는, 칩의 수를 늘림으로써 동시에 여러 작업을 수행함으로써 성능향상을 꾀하는 방향으로 나아가는데, 이를 활용하기 위해서는 반드시 멀티스레드 방식의 프로그래밍을 활용해야 합니다. 이 포스트에서는 이 멀티스레드의 기초인 스레드(Thread)와 애플 플랫폼에서 이를 적용하는 방법에 대해 알아보겠습니다.

이 포스트는 다음 가이드를 참고로 하여 작성되었습니다.

Threading Programming Guide

  • 스레드란 무엇인가?

    스레드는 프로세스의 경량화 버젼으로, 한 애플리케이션 내에서 여러 개의 코드를 실행하는 것을 가능하게 해줍니다. 하나의 애플리케이션은 하나의 프로세스를 가지고, 하나의 프로세스는 여러개의 스레드를 가질 수 있습니다. 이 여러개의 스레드는 개별적으로 돌면서 여러 작업들이 동시에(혹은 거의 동시에) 수행될 수 있도록 합니다. 시스템은 이러한 스레드를 관리하며 가용한 코어에 스케쥴링합니다.

    스레드는 기술적으로 시스템 레벨의 자료구조와 애플리케이션 레벨의 자료구조를 필요로 하는데, 시스템 레벨에서는 전체 스레드의 스케쥴링 작업을 위한 데이터를 저장하기 위해 필요하고, 애플리케이션 레벨에서는 스레드 자체의 상태와 실행흐름을 저장하기 위해 필요합니다.

    프로세스가 처음 실행될 때에는 1개의 스레드로 시작했다가(메인 스레드라고 합니다.), 필요에 따라 스레드가 생기나게 됩니다. 스레드는 각자가 별도의 시작 루틴을 가지고, 애플리케이션의 메인 루틴과 독립적으로 동작하게 됩니다.

  • 스레드의 장단점
    스레드가 가져다 줄 수 있는 이점은 크게 2가지가 있습니다.

    1. 반응성을 향상 시킵니다 : 스레드가 하나라면 이 하나의 스레드가 사용자 입력에 반응하고,뷰를 업데이트하고, 네트워크 작업을 수행하는 등의 모든 작업들을 수행해야 합니다. 하나의 스레드는 한번에 하나의 작업만 할 수 있기 때문에, 이 작업들을 모두 수행하다보면 느려질 뿐 아니라 상태 변화에 기민하게 대응을 할 수 없어 앱이 뚝뚝 끊기는 현상을 겪게 됩니다. 여러 스레드가 일들을 나눠 가진다면, 좀 더 부드러운 앱 경험을 제공할 수 있습니다.

    2. 멀티 코어 시스템에서 실제 성능 향상을 경험할 수 있습니다. 병렬적으로 수행될 수 있는 작업들을 여러 스레드에 나눠서 처리하게 함으로, 같은 일을 더 빠른 시간에 처리할 수 있게 됩니다.

    하지만 스레드가 만병통치약은 아닙니다. 잘못된 설계로 인해 스레드 동작 시간의 대부분이 대기시간으로 소비되버리는 등의 비효율적인 동작을 할 가능성이 있기 때문에, 위의 이점을 얻기 위해서는 스레드에 맞춰 프로그램을 설계해야하고, 이 과정에서 코드의 복잡성이 상당히 증가할 가능성이 있습니다. 동일한 프로세스 내에서의 스레드들은 모두 하나의 메모리를 공유하기 때문에, 동일한 데이터를 변경하려고 하면, 한쪽의 변경이 반영되지 않게 되어 정합성이 깨지는 문제가 생길 수 있습니다. 적절한 보호 장치를 걸어놔도 컴파일러 최적화 옵션에 의해 미묘한 버그가 생길 가능성도 있기 때문에 주의가 필요합니다.

  • 스레드 관리
    스레드는 크게 생성 -> 설정 -> 수행 -> 종료 의 4단계의 생명주기를 가지게 됩니다. 각 단계에서 할 수 있는 행동과 고려할 수 있는 요소들에 대해서 알아보겠습니다.

    1. 스레드 생성
      스레드를 생성하기 전에 알아두어야 할 것은 스레드 생성은 절대 공짜로 이루어지는 게 아니란 것입니다. 스레드는 커널 메모리 영역과 애플리케이션 메모리 영역을 모두 필요로 하기 때문에, 커널과의 통신 과정에서 결코 가볍지 않은 시간을 필요로 합니다.
    항목 비용 상세
    커널메모리 약 1KB 스레드 데이터와 속성들을 저장합니다. 이 데이터들은 페이징 될 수 없습니다.
    스택영역 512KB(일반 스레드), 8MB(OS X 메인 스레드), 1MB(iOS 메인 스레드) 일반 스레드의 스택 영역은 최소 16KB이고, 4KB의 배수여야 합니다. 해당 메모리는 스레드가 생성될 때 할당되지만, 실제 사용되기 전까지 페이지가 생성되지는 않습니다.
    생성 시간 약 90ms 스레드 생성 요청부터 스레드 루틴이 시작될 때 까지의 시간입니다. 측정은 Intel 2 GHz Core Duo/ 1 GB RAM/ OS X v10.5 사양의 아이맥에서 했습니다.

    실제로는 커널에서 스레드 풀(thread pool)을 통해 매 요청마다 스레드를 만드는 게 아니라 이미 있는 스레드를 재사용함으로써 스레드 생성 시간을 줄일 수 있도록 해줍니다.

    Foundation에서는 이 스레드를 추상화시켜 사용할 수 있도록 Thread(objective-c에서는 NSThread)클래스를 제공합니다. Thread 클래스를 이용해 만든 스레드의 자원은 시스템에 의해 자동으로 회수됩니다.(별도로 join연산을 수행할 필요가 없습니다)

    Thread 클래스를 활용하는 방법은 여러가지가 있습니다.

    1. class func detachNewThreadSelector(_ selector: Selector, toTarget target: Any, with argument: Any?)
      selector로 넘긴 메소드를 루틴으로 하는 스레드를 만들어 바로 실행시킵니다. selector에 들어갈 수 있는 메소드는 ()->Void 타입입니다. 해당 selector를 처리할 수 있는 객체를 target으로 넘기고, selector의 인자가 필요한 경우는 argument로 넘기면 됩니다.

      Objective-C에도 해당 메소드가 있는데, 3번째 인자의 타입이 id입니다. 즉, 클래스를 받도록 되어있습니다. swift 메소드 선언에서는 드러나지 않으나, 값타입의 인자를 사용하도록 메소드가 되어있다면 잘못된 값이 나오기 때문에, 반드시 selector 메소드 선언과 argument인자에 클래스 타입을 사용해야 합니다.

      iOS 10, macOS 10.12 이후에는 간단하게 클로저 블록을 인자로 받는 detachNewThread(_:)를 사용하는 것도 가능합니다.

    2. init(target:selector:object:) Thread객체를 직접 만들어 사용하는 방법입니다. 인자의 순서만 다를뿐, 생성 방식은 1번과 동일합니다. 이렇게 만들경우, 자동으로 시작하지 않기 때문에 명시적으로 start() 메소드를 호출하여야만 스레드가 시작됩니다.

    3. 서브클래싱 Thread 클래스를 서브클래싱할 경우, main() 메소드를 오버라이드 해야합니다. 해당 메소드는 스레드의 메인 루틴에 해당합니다. 이때 super 메소드는 호출할 필요가 없습니다. 이후 Init() 등으로 생성하여 start()를 호출해 스레드를 실행하면 됩니다.
    4. NSObject NSObject 클래스도 스레드를 만들 수 있는 메소드인 performSelector(inBackground:with:)를 제공합니다. 이 메소드의 기능은 detachNewThreadSelector에서 target을 해당 NSObject 객체로 지정한 것과 동일합니다.

    만약 기존 코드가 존재하여 재사용이 가능하거나, 멀티 플랫폼을 지원해야 한다면 POSIX API를 이용해 사용하는 것도 유용합니다.

      #include <assert.h>
      #include <pthread.h>
        
      void* PosixThreadMainRoutine(void* data)
      {
          // Do some work here.
        
          return NULL;
      }
        
      void LaunchThread()
      {
          // Create the thread using POSIX routines.
          pthread_attr_t  attr;
          pthread_t       posixThreadID;
          int             returnVal;
        
          returnVal = pthread_attr_init(&attr);
          assert(!returnVal);
          returnVal = pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
          assert(!returnVal);
        
          int     threadError = pthread_create(&posixThreadID, &attr, &PosixThreadMainRoutine, NULL);
        
          returnVal = pthread_attr_destroy(&attr);
          assert(!returnVal);
          if (threadError != 0)
          {
              // Report an error.
          }
      }
    

    pThread에 대한 자세한 기술적 내용은 pThread man page를 참조해주세요.

    이 경우, Cocoa 프레임워크와 섞어 쓰기 위해서는 몇가지 지켜야 할 사항이 있습니다.

    1. Cocoa 프레임워크는 멀티스레드 환경에서의 제대로 된 동작을 보증하기 위해 락(lock)을 사용합니다. 하지만 락이 성능에는 좋지 못하기 때문에, 새로운 스레드가 생성된 적이 없던 상황에서는 락을 사용하지 않고, 새로운 스레드를 생성하는 시점에서야 비로소 락을 사용하기 시작합니다. 하지만 POSIX 스레드만을 사용하면, Cocoa 프레임워크는 이 스레드의 생성을 감지할 수 없기 때문에, 락을 적용하지 못하게 되어 앱이 불안정해지거나 크래시 날 위험성이 생기게 됩니다. 이를 해결하기 위해서는 실행 후 바로 종료하는 Thread 인스턴스를 하나 만들어 줌으로, Cocoa 프레임워크에 신호를 보내줘야 합니다.

      현재 Cocoa 프레임워크가 멀티스레드 모드인지 체크하려면 Thread 클래스의 isMultiThreaded() 메소드를 사용하면 됩니다.

    2. Cocoa 프레임워크의 락인 NSLock과 POSIX의 락은 섞어 쓸 수 있습니다. 이는 NSLock이 POSIX 락의 Wrapper이기 때문입니다. 하지만, NSLock으로 건 락을 POSIX 락으로 해제하는 등의 방식으로 섞어 쓰는 것은 허용되지 않습니다.

  • 스레드 속성 설정하기
    가끔은 스레드의 설정을 변경하거나 특별한 기능을 사용할 필요가 있을 수도 있습니다. 여기서는 관련 기능들과 방법을 살펴보겠습니다.

    1. 스레드 스택 크기 변경 : 스레드가 개별적인 스택을 가지고, 어느정도의 크기를 기본 값으로 가지는 지 위에서 살펴보았습니다. 이 값을 사용자가 변경해 줄 수 있는데, 당연히 스레드가 시작되기 전에 설정이 되어야만 합니다.

      1. Cocoa : stackSize 프로퍼티를 사용하여 설정하면 됩니다.

      2. POSIX : pthread_attr 구조체를 만들고, pthread_attr_setstacksize 함수로 값을 변경합니다. 이후 스레드 생성시 해당 pthread_attr 구조체를 넘겨줍니다.

    2. 스레드 로컬 저장소 : 각 스레드는 스레드 내에서 자유롭게 접근할 수 있는 key-value 쌍의 저장소를 가집니다.

      1. Cocoa : threadDictionary 프로퍼티를 이용하여 사용이 가능합니다.

      2. POSIX : pthread_setspecific 함수와 pthread_getspecific 함수를 통해 사용할 수 있습니다.

    위의 두 방법은 서로 섞어쓸 수 없고, 각자의 방법을 이용해야 합니다.

    1. Detached 상태 설정 : 스레드는 Detached/Joinable의 두가지 상태를 가집니다.

      1. Detached : 스레드가 종료되면 바로 자원이 반환됩니다. 프로그램의 다른 부분과 통신할 수 있는 방법이 제공되지 않아서, 해당 스레드의 결과를 받는 것은 프로그래머의 재량에 달려있습니다.

      2. Joinable : 스레드가 종료되어도 명시적으로 메인 스레드에서 Join해주지 않으면 자원이 반환되지 않습니다. 종료 이전에 Join이 호출되면 스레드가 종료될 때 까지 기다립니다. Join할 때 Join을 호출한 스레드는 스레드의 결과값을 반환 값으로 받을 수 있습니다.

      애플리케이션이 종료될 때, Detached 스레드는 바로 종료되지만 Joinable 스레드는 바로 종료되지 않고, 스레드가 종료될 때 까지 기다립니다. 따라서 Joinable 스레드는 중단되면 안되는 중요한 작업(ex. 데이터 저장 등)에 적용하는 것이 좋습니다.

      Cocoa에서는 Joinable 스레드를 만드는 법이 제공되지 않기 때문에, POSIX 스레드를 사용해야 합니다. POSIX 스레드의 기본 값은 Joinable이고, 이를 변경하기 위해서는 pthread_attr_setdetachstate 함수를 이용해서 값을 설정한 뒤 스레드를 만들어야 합니다. joinable 스레드 생성 후에 이를 detached로 변경하려면 pthread_detach 함수를 호출해주면 됩니다.

    2. 스레드 우선순위 설정 : 커널에서 스케쥴링 할 때, 이 우선순위를 고려하게 됩니다. 다만 우선순위가 높다고 해서 해당 스레드의 실행 시점을 보장할 수 있는 것은 아닙니다.

      1. Cocoa : setThreadPriority 메소드로 현재 스레드의 우선순위를 조정 가능합니다.

      2. POSIX : pthread_setschedparam 함수를 이용할 수 있습니다.

    일반적으로는 우선순위를 기본값으로 두는 것이 좋습니다. 한 스레드의 우선 순위를 높이면 낮은 우선순위를 가진 스레드들은 선택받지 못하고 정체되는 기아 현상(starvation)이 나타나게 됩니다. 특히, 높은 우선순위의 스레드와 낮은 우선순위의 스레드가 상호작용하는 경우에는, 낮은 우선순위의 스레드가 기아 현상을 겪으면서 전체 성능을 제한하는 경우가 생깁니다.

  • 스레드 메인 루틴 작성 메인 루틴의 구조는 어떤 플랫폼에서나 거의 동일합니다. 필요한 데이터 구조를 초기화하고, 작업을 수행하고, 필요시에는 RunLoop를 설정하거나, 작업이 끝나면 메모리를 정리하는 과정이죠. 그리고 디자인에 따라 몇가지 더 고려할 요소들이 있습니다.

    1. 메모리 관리 : 스레드 내에서 생긴 클래스 인스턴스들은, 스레드가 종료될 때 까지 해제되지 않습니다. 오랜 시간 동작해야 하는 스레드의 경우는, 사용되지 않는 클래스가 오랜시간 남아있지 않도록 정리해주는 작업이 필요할 수 있습니다.

    2. 예외 처리 : 스레드에서 발생한 에러는 스레드 내에서 처리되어야 합니다. 스레드 내에서 에러가 처리되지 않을 경우, 전체 애플리케이션이 종료되어 버립니다.

    3. RunLoop 설정 : 위에서 보았던 스레드들은 모두 중단 없이 쭉 실행되고 종료되는 스레드였습니다. 하지만 내부적으로 루프를 가지고 요청을 받을 때마다 해당 요청을 동적으로 수행하는 스레드도 가능한데, 이를 위해서 필요한 것이 RunLoop입니다.

    iOS와 macOS는 RunLoop를 내장으로 지원하고 있습니다. AppKit과 UIKit은 애플리케이션의 메인 스레드의 RunLoop를 자동으로 설정하여 제공해줍니다 만약 다른 스레드에서도 RunLoop가 필요하다면 직접 설정하여 사용하여야 합니다.

    RunLoop에 대한 내용은 다음 포스트에서 다루겠습니다.

  • 스레드 끝내기 스레드를 끝내는 정상적인 방법은 스레드의 메인 루틴이 정상적으로 종료되는 것입니다. Cocoa나 POSIX 모두 스레드를 중단하는 방법을 제공하지만, 권장하지 않습니다. 스레드가 비정상 종료되면 스레드가 작업을 정리할 수 있는 기회를 없애버리기 때문입니다.

    만약 스레드가 중간에 종료될 필요성이 존재할 것이라 생각된다면, 만들 때부터 중단이나 종료 메시지에 반응하도록 만들어야 합니다. 예를들어 긴 작업을 수행할 경우 중간중간 작업을 멈추고, 중단 메시지가 왔는지 체크하는 것을 생각해 볼 수 있습니다. 만약 중단 메시지가 온 경우, 스레드에게 종료할 수 있는 지 여부를 묻고, 스레드는 하던 것을 깔끔하게 정리할 수 있고, 아닌 경우는 하던 작업을 계속할 수 있게 하는 것이죠.

    이것을 구현하는 방법 중 하나는 RunLoop의 input source를 이용하는 것입니다.

      func main() {
          var moreWorkToDo = true
          var exitNow = false
          let runLoop = RunLoop.current
    
          var threadDict = Thread.current.threadDictionary
          threadDict.setValue(NSNumber(value:exitNow), forKey: "ThreadShouldExitNow")
    
          self.myInstallCustomInputSource() // InputSource를 설치하는 커스텀 메소드
    
          while moreWorkToDo, !exitNow {
                
              // 큰 작업의 일부를 수행한다.
              // 종료되면 moreWorkToDo를 false로 바꾼다.
    
              runLoop.run(until:Date()) // runLoop를 실행하되, input source가 발생할 이벤트가 없으면 바로 타임아웃시킨다.
    
              exitNot = threadDict["ThreadShouldExitNow"]. // input source가 dictionary의 값을 바꿨는지 확인한다.
          }
      }
    
    

스레드에 대해서 살펴보았습니다. 다음 포스트에서는 RunLoop, 스레드의 싱크 문제와 그 해결책, 스레드 대신 사용할 수 있도록 제공하는 방법들에 대해서 살펴보도록 하겠습니다.