iOS 공부하는 감자

Operation, OperationQueue (feat. GCD) 본문

Swift

Operation, OperationQueue (feat. GCD)

DongTaTo 2022. 12. 28. 17:53
반응형

순서

  1. 동시성 프로그래밍
  2. Operation과 비교해볼 GCD 기능
  3. Operation
  4. OperationQueue
  5. Qos
  6. Block Operation
  7. 비동기 함수를 호출하는 Operation
  8. 종속성
  9. 작업 취소

 

동시성 프로그래밍

분산 처리가 필요한 작업들을 여러 스레드로 분산시켜서 처리하는 프로그래밍 방식

Swift에서는 동시성 프로그래밍 구현을 위한 2가지 방법을 제공한다.

  1. GCD (DispatchQueue)
  2. Operation - OperationQueue

자바나 C언어같은 경우, 개발자가 동시성 프로그래밍을 구현하기 위해 직접 스레드를 생성하고 수행할 작업을 지정해야 한다.

Swift에서는 동시적으로 사용할 스레드의 개수를 직접 관리하지 않고, Queue라는 대기행렬로 작업을 전달하면 OS가 알아서 여러 스레드로 분배하여 처리한다.

따라서 기본적으로는 Queue로 전달된 작업이 어떤 스레드에서 실행될지, 몇개의 스레드를 사용될지 알 수 없다. (OperationQueue의 경우, Queue에서 사용될 스레드의 개수를 설정 가능)

 

 


 

Operation과 비교해볼 GCD의 기능

  1. DispatchGroub
  2. Dispatch Work Item

 

1. Dispatch Groub

여러 작업을 동시적, 비동기적으로 처리하고 모든 작업이 종료되는 시점을 파악하여 필요한 처리를 하기 위해 사용

DispatchGroub의 notify 메서드를 통해 종료 시점을 파악 가능

 

 

2. Dispatch Work Item

WorkItme은 Operation과 비슷한 부분이 많다.

클래스로 구현되어 있는 WorkItem을 인스턴스화해서 하나의 작업 단위를 생성한다.

 

WorkItem클래스의 생성자 메서드로 작업의 qos와 작업에서 수행할 작업을 클로저 구문으로 작성할 수 있고

생성한 작업은 DispatchQueue로 보내서 실행시킬 수 있다.

 

DispatchWorkItem은 작업 단위를 객체로 만들어서 사용하기 때문에, 다음과 같은 기능을 사용 가능하다.

  1. 작업의 취소
  2. 작업의 순서 설정

 

1. 작업 취소

WorkItme의 인스턴스 메서드 cancel()을 통해 간편하게 작업의 취소가 가능하다.

WorkItem 작업의 취소 시점에 따라 취소 결과가 2가지로 구분된다.

  1. WorkItem이 Queue에서 대기중인 경우 : Queue에서 작업이 제거되고 실제로 실행되지 않는다.
  2. WorkItem이 스레드로 전달되어 실행중인 경우 : 작업이 실제로 취소되지 않고, 내부 프로퍼티값 isCancelled를 true로 설정된다.

isCancelled속성이 true로 설정된 WorkItem은 이후에 Queue로 전달되었을 때, 실행되지 않는다.

 

2. 작업 순서 설정

notify메서드를 통해 해당 작업이 종료된 후, 실행시킬 작업을 설정 가능하다.

위 코드처럼 2개의 WorkItem을 생성한 후, notify를 설정하고 Queue로 전달하여 실행시키면

Queue로 전달된 작업이 종료된 후, 다음 작업이 알아서 실행된다.

 

 


 

Operation

Operation은 추상클래스이며, 이 클래스를 직접 사용하는 것이 아니라 Operation을 상속받는 클래스를 정의하여 작업의 단위로 사용한다.

main()메서드를 재정의하여 Operation에서 수행할 작업을 정의할 수 있고,

클래스를 직접 정의하기 때문에 작업에 필요한 프로퍼티도 내부적으로 정의해줄 수 있다. (inputValue, outputValue..)

 

WorkItem과 비슷한 느낌이지만.. 메서드 형태의 코드만 사용하여 인스턴스를 생성하는 WorkItem과 다르게

내부적으로 사용할 프로퍼티와 메서드를 자유롭게 정의할 수 있는 Operation은 더 활용성이 높다.

 

 

Operation도 개별 작업을 객체로 만들어서 사용하기 때문에 다음과 같은 기능을 사용할 수 있다.

1. completionBlock 설정

 

2. 우선순위 설정 가능 (Qos)

let operation1 = ConvertOperation()
operation1.qualityOfService = .background

 

3. 개별 작업을 직접 실행

let operation1 = ConvertOperation()
operation1.qualityOfService = .background

operation1.completionBlock = {
    print("operation1 ===>")
}

operation1.start()     // operation1 ===>

 

Operation의 생명주기 (상태값)

ViewController처럼 Operation에도 생명주기와 같은 상태값이 프로퍼티로 존재한다.

Pending : 인스턴스로 생성된 Operation이 Queue로 전달된 상태

Ready : Pending상태의 Operation이 스레드로 전달되어 실행할 준비가 된 상태

Executing : Operation이 실행중인 상태

Finished : Operation의 작업이 종료된 상태

 

Finished상태가 아니라면 언제든 cancel()메서드를 통해 Cancelled상태로 들어갈 수 있다.

Operation에는 프로퍼티로 상태값이 있기 때문에 KVO를 통해 관련된 처리를 수행할 수도 있다.

 

 

 


 

 

Operation Queue

Operation을 동시적 + 비동기적으로 실행시키기 위해 사용하는 Queue

기본적으로 여러 스레드를 활용하여 Queue로 전달된 Operation들을 동시적으로 실행되지만

GCD와 다르게 Queue에서 사용될 스레드의 개수를 직접 설정이 가능하다.

 

 

maxConcurrentOperationCount

OperationQueue에서 사용될 스레드의 개수를 직접 설정할 수 있는 프로퍼티이다.기본값은 -1이며, 전달받은 Operation을 알아서 동시적으로 여러 스레드를 활용하여 처리한다.이 값을 1로 설정하면 직렬큐 방식으로 동작한다.

let queue = OperationQueue()

queue.maxConcurrentOperationCount = -1   // 기본값

queue.maxConcurrentOperationCount = 1    // 직렬큐로 동작

 

 

maxConcurrentOperationCount 속성을 통해 Queue가 생성된 이후에도 동작 방식을 자유롭게 변경할 수 있다. (직렬, 동시)

 

OperationQueue로 작업을 보내는 방법

  1. 클로저 구문 활용
  2. Operation 인스턴스를 생성하여 전달
  3. Operation 인스턴스 배열을 전달

배열을 전달할 때 설정하는 waitUntilFinished를 true로 설정하면, 배열로 전달한 Operation들이 모두 종료될 때까지 동기적으로 기다리겠다는 의미이다.

 

 

waitUntilAllOperationsAreFinished()

  • queue로 전달된 모든 작업이 끝나는 것을 기다리는 메서드
  • 이 코드를 읽으면 이전까지 queue에 전달된 모든 작업을 끝날떄까지 해당 라인에서 동기적으로 기다린다.
  • 메인스레드에서 호출하면 화면이 버벅일 수 있기 때문에 다른 스레드에서 작업의 순서를 보장하는 용도로 사용하면 좋다.
queue.addOperation {
    sleep(3)
    print("1")
}

queue.addOperation {
    sleep(3)
    print("2")
}

queue.addOperation {
    sleep(3)
    print("3")
}

queue.waitUntilAllOperationsAreFinished()
print("finish")

// 1
// 3
// 2
// finish

 

 


 

 

Qos

Operation & OperationQueue에서 사용되는 Qos는 3가지 종류가 있다.

  1. OperationQueue에 대한 Qos
  2. Operation에 대한 Qos
  3. DispatchQueue에 대한 Qos (OperationQueue의 내부적으로 동작하는 Queue)

OperationQueue는 DispatchQueue를 기반으로 동작하기 때문에 내부적으로 사용할 DispatchQueue에 대한 Qos도 설정 가능하다.

 

 

Qos 적용의 우선순위

DispatchQueue  >  Operation  >  OperationQueue

 

때문에, OperationQueue는 기본적으로 FIFO 방식으로 동작하면서도 대기열로 전달된 각 Operation의 Qos에 따라 작업의 실행 순서가 달라진다.

ex) background Qos작업이 먼저 Queue로 들어왔더라도, 그 다음에 들어온 Operation의 Qos가 userInteractive라면 Qos가 더 높은 Operation이 먼저 스레드로 전달되어 실행

 

 


 

Block Operation

하나 이상의 블록(클로저 단위의 작업)의 동시 실행을 관리하는 클래스이다.

일반적인 Operation은 각 작업 단위를 클래스로 만들고, 인스턴스화하여 사용해야 하지만 BlockOperation은 각 작업의 단위를 클로저 구문으로 작성하여 전달하면 해당 블록(작업)들을 동시적으로 실행시킨다.

CompletionBlock을 사용하여 모든 블록의 실행이 종료되는 시점에 필요한 처리를 할 수 있다.

let blockOperation = BlockOperation()

// CompletionBlock 설정
blockOperation.completionBlock = {
    print("작업 종료")
}

// BlockOperation에 블록(작업) 추가
blockOperation.addExecutionBlock { print("1번 작업") }
blockOperation.addExecutionBlock { print("2번 작업") }
blockOperation.addExecutionBlock { print("3번 작업") }
blockOperation.addExecutionBlock { print("4번 작업") }
blockOperation.addExecutionBlock { print("5번 작업") }

 

Operation 자체에서 start()메서드를 통해 실행시키면 메인 스레드에서 동기적으로 동작하는것 처럼

BlockOperation도 기본 설정은 동기적으로 동작한다.

즉, 메인스레드에서 BlockOperation을 사용하여 작업을 시작하면 completionBlock이 호출되기 전까지 메인스레드는 다른 작업을 할 수 없다. (종료시점을 동기적으로 기다린다.)

 

BlockOperation은 Operation을 상속받아서 만들어진 것.

Operation에서 사용되는 기능들 또한 당연히 사용할 수 있다. (순서지정, 취소, Qos설정 등등)

그리고 당연히… 얘도 싱글샷 객체이다. (1회성)

 

BlockOperation 정리

  • 각 블록(작업)은 동시적으로 여러 스레드를 사용하여 실행된다. (기본적으로)
  • 기본 설정은 동기적으로 동작하기 때문에, 모든 Block이 종료되기 전까지 BlockOperation을 실행시킨 스레드는 잠긴다.
  • 비동기적으로 실행시키기 위해서는 다른 스레드에서 비동기적으로 실행하거나 OperationQueue로 전달하여 실행해야 한다.
  • DispatchGroub에서 동일 그룹으로 묶인 작업들이 동시적으로 실행된 후, notify를 통해 종료시점을 파악할 수 있던 것과 비슷하다.
  • 각 작업을 클로저 구문으로 간편하게 작성할 수 있어서 좀 더 가볍게(?) 사용할 수 있는 Operation
let blockOperation = BlockOperation()

blockOperation.completionBlock = {
    print("작업 종료")
}

blockOperation.addExecutionBlock { print("1번 작업") }
blockOperation.addExecutionBlock { print("2번 작업") }
blockOperation.addExecutionBlock { print("3번 작업") }
blockOperation.addExecutionBlock { print("4번 작업") }
blockOperation.addExecutionBlock { print("5번 작업") }


// 1. start() 메서드를 통해 동기적으로 실행 =====================
blockOperation.start()

print("동기적으로 실행되는지 테스트")

// <출력> : 동시적으로 다양한 스레드에서 실행되기 때문에 순서가 보장되지 않았고, 모든 Block이 끝난 후 completionBlock을 실행하고, 그 뒤의 코드를 실행함 (동기적으로 종료를 기다렸음)
// 5번 작업
// 1번 작업
// 4번 작업
// 2번 작업
// 3번 작업
// 작업 종료
// 동기적으로 실행되는지 테스트



// 2. Queue로 보내서 비동기적으로 실행 =========================
let queue = OperationQueue()

queue.addOperation(blockOperation)

print("동기적으로 실행되는지 테스트")
// 동기적으로 실행되는지 테스트
// 3번 작업
// 1번 작업
// 2번 작업
// 5번 작업
// 4번 작업
// 작업 종료



// 3. Queue의 max스레드를 1로 설정한 후, 비동기적으로 실행 =========================
let queue = OperationQueue()
queue.maxConcurrentOperationCount = 1

queue.addOperation(blockOperation)

print("동기적으로 실행되는지 테스트")
// 동기적으로 실행되는지 테스트
// 1번 작업
// 2번 작업
// 3번 작업
// 4번 작업
// 5번 작업
// 작업 종료

 

 

 

 


 

 

비동기 함수를 호출하는 Operation

Operation의 작업에서 비동기 함수를 호출하는 경우, 다른 스레드로 작업이 넘어가기 때문에 Operation의 정확한 작업 종료 시점을 파악하기 어렵다.

 

GCD의 경우에는 DispatchGroub의 enter, leave를 활용하여 작업의 종료 시점을 파악할 수 있었다.

Operation에서는 내장(?)된 상태값을 수동으로 관리함으로써 작업 종료 시점을 파악할 수 있다. (isReady, isExecuting, isFinished)

하지만 이런 상태값 프로퍼티는 모두 read-only이기 때문에 직접 설정이 불가능하다.

 

Operation을 상속받는 새로운 클래스를 커스텀하게 만들어서 사용해야 한다. (애플에서 기본 제공하지 않음)

https://gist.github.com/jemmons/b1c84130a7dcf0fc1f11

https://blog.bitbebop.com/asynchronous-operations-swift/

class AsyncOperation : NSOperation{
  enum State{
    case Waiting, Executing, Finished
  }
  
  
  var state = State.Waiting{
    willSet{
      switch(state, newValue){
      case (.Waiting, .Executing):
        willChangeValueForKey("isExecuting")
      case (.Waiting, .Finished):
        willChangeValueForKey("isFinished")
      case (.Executing, .Finished):
        willChangeValueForKey("isExecuting")
        willChangeValueForKey("isFinished")
      default:
        fatalError("Invalid state change in AsyncOperation: \(state) to \(newValue)")
      }
    }
    didSet{
      switch(oldValue, state){
      case (.Waiting, .Executing):
        didChangeValueForKey("isExecuting")
      case (.Waiting, .Finished):
        didChangeValueForKey("isFinished")
      case (.Executing, .Finished):
        didChangeValueForKey("isExecuting")
        didChangeValueForKey("isFinished")
      default:
        fatalError("Invalid state change in AsyncOperation: \(oldValue) to \(state)")
      }
    }
  }
  
  
  override var executing:Bool{
    return state == .Executing
  }
  
  
  override var finished:Bool{
    return state == .Finished
  }
  
  
  override var asynchronous:Bool{
    return true
  }
  
  
  override init() {
    super.init()
    addObserver(self, forKeyPath: "isCancelled", options: [], context: nil)
  }
  
  
  deinit{
    removeObserver(self, forKeyPath:"isCancelled")
  }
  
  
  override internal func observeValueForKeyPath(keyPath: String?, ofObject object: AnyObject?, change: [String : AnyObject]?, context: UnsafeMutablePointer<Void>) {
    guard keyPath == "isCancelled" else{
      return
    }
    if cancelled{
      didCancel()
    }
  }
  
  
  override func start() {
    guard NSThread.isMainThread() else{
      fatalError("AsyncOperation should only run on the main thread.")
    }
    
    guard !hasCancelledDependencies else{
      cancel()
      return
    }
    
    guard !cancelled else{
      return
    }
    
    state = .Executing
    main()
  }
  
  
  /// Where the main work of the operation happens. Subclasses can override this method to do their own work. If they do so, they *must* call `finish()` when the work is complete. Because this is an asynchronous operation, the actual call to `finish()` will usually happen in a delegate or completion block.
  override func main() {
    finish()
  }
  
  
  /// Gets called whenever the operation becomes cancelled (i.e. `isCancelled` becomes `true`). Subclasses can override this to cancel and tear down any work that's happening in `main()`. If they do so, they *must* call `finish()` when complete.
  func didCancel(){
    finish()
  }
  
  
  func finish(){
    state = .Finished
  }
}



private extension AsyncOperation{
  var hasCancelledDependencies:Bool{
    return dependencies.reduce(false){ $0 || $1.cancelled }
  }
}

 

 

 


 

종속성 (순서 관리)

Operation에서 addDependency() 메서드를 통해 의존성을 부여할 수 있다.

operation1.addDependency(operation2)

종속성을 부여하면, 종속성이 부여된 모든 작업의 실행이 종료되기 전까지 해당 Operation이 실행되는 것을 방지한다.

  • queue로 Operation이 전달되었어도 종속성이 부여된 Operation이 종료되기 전에는 실행되지 않는다.
  • Operation들이 서로 의존성을 갖지 않도록 주의해야 한다. (의존성 사이클이 발생하지 않도록!)

 

애플은 WWDC앱의 즐겨찾기 버튼이 Tap되는 시점에 사용되는 Operation이 사용되었다고 설명한다. (WWDC에서)

즐겨찾기 버튼을 사용자가 누르면

  1. 로그인 Operation을 생성한다. (개발자 이름과 비밀번호로 앱에 로그인했음을 보장하는 Operation)
  2. 사용자정보 Operation 생성 (사용자 이름과 암호가 실제 개발자 이름과 암호가 맞는지 보장)

이런 과정을 통해 사용자의 애플아이디가 적절한 개발자 애플아이디인지 확인하고

WWDC앱의 즐겨찾기는 CloudKit에 저장되므로, iCloud 계정에 접근할 수 있는지에 대한 또 다른 Operation이 생성된다.

 

이런 과정이 모두 종료된 후, 마지막으로 즐겨 찾기 저장 작업을 실행할 수 있으며 이는 개발자임을 검증하는 로직이 성공적으로 완료되고 iCloud 계정이 있는지 확인하는 로직이 성공적으로 완료했는지 여부에 달려 있다.

 

종속성이 설정된 Operation들의 데이터 전달

종속성이 부여된 Operation들끼리는 Protocol을 사용하여 데이터를 전달할 수 있다.

전달하려는 Operation에서 Protocol을 채택하고, 전달받는 Operation에서는 해당 프로토콜 타입을 통해 값을 가져온다. (delegate 패턴을 사용한 값전달과 비슷하다)

 

[ UIImage를 전달하는 Operation 예시 ]

 

1. 프로토콜 정의

protocol ImageProvider {
    var image: UIImage? { get }
}

 

2. 전달하려는 Operation에서 프로토콜 채택

class LoadImageOperation: Operation {
    
    var loadedImage: UIImage?
    
    override func main() {
        
        // 이미지를 가져와서 loadedImage에 넣어주는 코드 ...
    }
}

extension LoadImageOperation: ImageProvider {
    var image: UIImage? { return loadedImage }
}

 

3. 이미지를 전달받는 Operation에서는 프로토콜 타입을 활용하여 Image를 가져온다.

  • dependencies는 종속성이 걸린 Operation 배열을 가져온다.
  • filter + is를 사용하여 프로토콜이 채택된 Operation을 가져와서 타입캐스팅하여 사용
class ImageConversionOperation: Operation {
    var inputImage: UIImage?
    var resultImage: UIImage?
    
    override func main() {
        if inputImage == nil {
            let dependencyImageProvider = dependencies
                .filter { $0 is LoadImageOperation }
                .first as? LoadImageOperation
            inputImage = dependencyImageProvider?.loadedImage
        }
        
        // 이미지를 변환하여 resultImage에 넣어주는 코드 ...
    }
}

 

4. 의존성 부여하고 Queue로 전달

let loadImageOperation = LoadImageOperation()
let imageConversionOperation = ImageConversionOperation()

// ⭐️
imageConversionOperation.addDependency(loadImageOperation)

OperationQueue().addOperations([loadImageOperation, imageConversionOperation], waitUntilFinished: true)

 

 

작업 취소

DispatchWorkItem처럼 cancel 메서드를 실행하면 내부 프로퍼티인 isCancelled를 true로 설정한다.

loadImageOperation.cancel()

 

 

cancel() 메서드가 실행되었다고 해서 Operation이 실제로 멈추는 것은 아니기 때문에

  1. isCancelled 프로퍼티 값을 활용하여 내부적으로 동작이 멈추도록 따로 구현이 필요하고
  2. isFinished 프로퍼티 값을 true로 설정하여 실제로 Operation이 취소+완료 되도록 구현해야 한다.
guard isCancelled == false else { return }

 

OperationQueue에서 cancelAllOperations() 메서드를 사용하여 queue에 추가되어 있는 모든 Operation의 isCancelled 프로퍼티를 true로 설정할 수도 있다.

let queue = OperationQueue()

queue.cancelAllOperations()

 

 

 

 

 

반응형

'Swift' 카테고리의 다른 글

Swift) Choosing Between Structures and Classes  (0) 2023.01.30
Swift) Property Wrapper  (0) 2022.07.20
split() vs components()  (0) 2022.06.04
문자열 다루기  (0) 2022.01.31