Migrate to Swift 6(1) - Actor 타입

이정훈·2024년 10월 3일
0

Swift 파헤치기

목록 보기
11/12
post-thumbnail

Swift 6로 마이그레이션 하기 위해 Swift 5.X 버전부터 발판을 만들어둔 새로운 개념을 학습하기 위한 시리즈, 그 첫번째로 Actor 타입에 관한 내용이다.

Swift에서 동시성 프로그래밍

Swift멀티 스레드를 기반으로 동시성 프로그래밍이 가능한 프로그래밍 언어이다. 멀티 스레드란 하나의 프로세스 내에 둘 이상의 스레드동시에 작업을 수행 할 수 있음을 의미한다. 여기서 중요한 점은 하나의 프로세스에서 동시에 작업을 수행하면서 여러 개의 스레드자원공유한다는 것이다.

데이터 경합의 문제

이러한 멀티 스레드 환경에서 문제가 될 수 있는 부분은 바로 데이터 경합(Data Race)이 발생할 여지가 있다는 것이다.

데이터 경합이란 서로 다른 스레드공유하는 데이터를 동시에 읽고, 쓰는 작업을 수행하면서 그 순서에 따라 잘못된 값읽거나 쓰는 상황을 말한다.

가령, 아래의 코드는 데이터 경합이 발생할 수 있는 상황을 보여주는 예시이다.

import Foundation

class Counter {
    var count: Int = 0
    
    func add() {
        self.count += 1
    }
}

let counter = Counter()

for _ in 0..<1000 {
    // global
    DispatchQueue.global().async {
        counter.add()
    }
    // main
    counter.add()
}

sleep(1)
print(counter.count)

//    총 5회 실시
//    1999
//    1999
//    2000
//    1997
//    2000

for문을 이용하여 1000번을 반복하면서 global 큐와 main 큐에서 각각 한번씩 1을 더하는 과정을 수행하였다. 다시말해 1000번을 반복하면서 1회 반복 시 마다 add()가 두 번씩 호출되는 셈이므로 예상되는 결과 값은 2000이 출력될 것으로 예상된다. 하지만 위의 코드를 총 5회 반복 실시 후 결과를 확인해 보면 실행할 때마다 값이 일정하지 않고 다른 값이 나오는 것을 확인할 수 있었다.

이러한 상황이 발생하는 이유는 global 큐와 main 큐를 사용하면서 하나의 프로세스 내에서 여러 스레드가 동시에 같은 데이터를 읽고 쓰기 때문에 데이터 경합 조건이 형성되기 때문이다. 만약 두 스레드가 동일한 시점에 add() 메서드를 호출하면 문제가 발생할 수 있다.

예를 들어, countercount 값이 0인 상태를 가정해보자. 한 스레드에서 동기적으로 두 번 add()가 호출된다면, 첫 번째 호출로 count 값이 1이 되고, 두 번째 호출로 1에 다시 1을 더해 최종적으로 2가 될 것이다. 하지만 서로 다른 스레드가 동시에 add()를 호출하면, 첫 번째 스레드도 0을 읽어오고 두 번째 스레드도 0을 읽어 온 후 각각 1을 더한 뒤, 동시에 count1을 할당하게 되어 최종 값이 1로 남게 된다. 이로 인해 기대한 결과와 다른 값이 나오는 문제가 발생한다.

Swift 6 language mode에서는 이러한 데이터 경합이 발생할 여지가 있는 경우 컴파일단에서 파악하여 사전에 방지한다.

Actor 타입의 등장

actorWWDC 2021에서 소개된 새로운 타입으로 shared mutable state에 대한 동기화 메커니즘을 제공한다. 다시 말해 위와 같이 동시성 환경에서 데이터 경합이 발생할 수 있을 때 데이터 안전성을 보장할 수 있는 타입이라고 할 수 있다. actor 타입이 여러 스레드에서 공유 가능한 데이터를 안전하게 동기화 할 수 있는 방법은 데이터 손상이 일어날 수 있는 작업이 완전히 종료 될 때까지 다른 스레드에서 접근할 수 없도록 일시 중단하기 때문이다. 마치 NSLock을 사용하는 것처럼 데이터에 접근하기 전 lock을 걸고 작업이 완료된 후 lock을 해제하는 것처럼 비슷하게 돌아가는 것 같지만 이러한 매커니즘을 개발자가 직접 구현할 필요는 없다. actor 타입이 알아서 해줄 거니까..

먼저 actor 타입 또한 일종의 타입이므로 class, struct, enum 등 다른 타입을 선언할 때와 동일하게 선언하여 사용할 수 있다.

import Foundation

actor Counter {
    var count: Int = 0
    
    func add() {
        self.count += 1
    }
}

다음으로 위에서 설명한 코드를 actor를 사용하여 리팩토링 하면 아래와 같이 구현할 수 있다.

let counter = Counter()

for _ in 0..<1000 {
    // global
    DispatchQueue.global().async {
        Task {
            await counter.add()
        }
    }
    // main
    await counter.add()
}

sleep(1)
print(await counter.count)

//    총 5회 실시
//    2000
//    2000
//    2000
//    2000
//    2000

위에서 언급한 것과 같이 actor 타입은 여러 스레드에서 공유할 수 있는 데이터를 사용할 때는 그 데이터의 안전이 확보될 때까지 일시 중단한다고 했다. 따라서 여러 스레드에서 동시적으로 접근 가능한 actor 타입 외부에서 actor 타입의 메서드를 호출하는 경우 await 키워드로 일시 중단하여 해당 지점으로부터 안전이 확보될 때까지 Suspension Point를 설정한다.

따라서 actor 타입을 사용하는 경우 데이터 안전성이 보장되어 5회 반복 실행 결과, 동일하게 2000이라는 결과 값을 일정하게 출력하는 것을 확인할 수 있다.

Actor 타입의 특징

Swiftactor는 다음과 같은 특징을 가진다.

  • actor도 일종의 타입이다.
  • 따라서 다른 타입에서 사용하는 프로퍼티, 메서드, 이니셜라이저, 서브스크립트 등을 모두 사용 가능하다.
  • 다른 타입과 마찬가지로 protocol 준수, extension도 모두 사용 가능하다.
  • class와 동일하게 참조 타입이다.
  • 하지만 class와 달리 상속이 불가능하다.

actor isolation

actor 타입과 다른 타입들의 가장 큰 차이점은 위에서도 계속 언급하고 있듯이 데이터 경합으로부터 actor의 상태를 보호한다는 것이다. 그리고 actor의 가변 상태 데이터를 보호하기 위해 actor와 그 인스턴스 멤버에 접근 방식에 제한을 주게 되는데 이것을 actor isolation이라고 한다.

actor isolationactor의 저장 인스터스 프로퍼티를 self를 통해서만 접근이 가능하도록 제한한다.

다음은 BankAccount라는 actor 타입을 선언하고 transfer(amount:to:) 메서드를 통해 계좌이체를 구현한 예시이다.

actor BankAccount {
  let accountNumber: Int
  var balance: Double

  init(accountNumber: Int, initialDeposit: Double) {
    self.accountNumber = accountNumber
    self.balance = initialDeposit
  }
}

extension BankAccount {
  enum BankError: Error {
    case insufficientFunds
  }
  
  func transfer(amount: Double, to other: BankAccount) throws {
    if amount > balance {
      throw BankError.insufficientFunds
    }

    print("Transferring \(amount) from \(accountNumber) to \(other.accountNumber)")

    balance = balance - amount
    other.balance = other.balance + amount  // error: actor-isolated property 'balance' can only be referenced on 'self'
  }
}

이때 transfer(amount:to:) 메서드와 같이 other.balanceactor의 프로퍼티에 직접적으로 접근하게 되면 에러가 발생하게 되는데 에러 메세지 중 actor-isolated라는 의미는 특정 actor 내부에서만 접근이 가능함을 의미한다. 즉, self 키워드를 통해서만 접근이 가능하지만 self로 접근하지 않았기 때문에 에러가 발생한 것이다. actor 타입에서 저장, 연산 프로퍼티, 인스턴스 메서드, 인스턴스 서브스크립트 등은 기본적으로 actor-isolated 상태이다. 동일한 actor 인스턴스 내부에서 선언되어 있는 서로 다른 actor-isolated의 접근은 자유롭게 참조 가능하다.

cross-actor reference

actor 외부에서 actor-isolated 상태의 선언을 참조하는 것을 cross-actor reference라고 한다. cross-actor reference는 두 가지 방법으로만 접근할 수 있도록 허용하는데 그 방법은 아래와 같다.

  • 불변 상태(상수)에 대한 cross-actor referenceactor가 선언 되어 있는 모듈 어디서나 접근이 가능하다. (한번 초기화되면 수정할 수 없기 때문에)

    따라서 위의 코드에서 상수로 선언 되어 있는 other.accountNumber 이런 식의 접근은 가능하다는 것이다.

  • 비동기 함수 내에서의 cross-actor reference는 허용된다.

    비동기 함수 호출은 actor비동기 함수를 안전하게 수행할 수 있도록 messages 형태로 변환된다. 이 messagesactormailbox라는 곳에 저장 되고 비동기 함수의 호출자는 mailbox에 저장된 messages가 실행될 때까지 대기할 수 있다. 또한 actormailbox는 한번에 하나의 messages가 수행됨을 보장하므로 가변 상태의 데이터 경합은 발생하지 않는 것을 보장한다.

cross-actor reference 조건을 바탕으로 위의 코드를 아래와 같이 리팩토링하여 에러를 제거할 수 있다.

extension BankAccount {
    ...
    
    func transfer(amount: Double, to other: BankAccount) async throws {
        if amount > balance {
            throw BankError.insufficientFunds
        }
        
        print("Transferring \(amount) from \(accountNumber) to \(other.accountNumber)")
        
        balance = balance - amount
        await other.deposit(amount: amount)
    }
}

extension BankAccount {
    func deposit(amount: Double) async {
        assert(amount >= 0)
        balance = balance + amount
    }
}

Reference

https://github.com/swiftlang/swift-evolution/blob/main/proposals/0306-actors.md

profile
새롭게 알게된 것을 기록하는 공간

0개의 댓글