JS-Coroutine 분해하기

azi_zero·2021년 11월 23일
3

leave a mark 🐾

목록 보기
6/6

원글은 Notion에서 작성되었습니다.
📌 원글 보러가기

해당 글은 본인의 개념 정리 용으로 작성하였으므로 불친절한 전달이라도 이해 부탁드립니다!

🌟 Coroutine 이해하기

헷갈릴 수 있는 concepts

Process & Thread

process는 Heap을 사용하며, thread는 process안에서 stack을 사용한다.

Concurrency & Parallelism

동시성과 병렬성의 개념을 명확히!

Concurrency ( 동시성 )

interleaving, 다수의 task들에 대해서 각각을 쪼개어서 조금씩 빠르게 실행하여
전체로 보았을때는 동시에 실행되고 있는 것처럼 보이도록 실행하는 것.

Parallelism ( 병렬성 )

parallelizing, 다수의 task들이 한번에 수행되는 것

Thread & Coroutine

두 개념 모두 Concurrency를 보장하기 위한 기술이다.

Thread - Thread의 효율성은 OS의 몫이다.

  • task의 단위 = Thread
    • 다수의 작업 각각에 Thread를 할당함 .
    • task는 stack 영역을 차지함.
  • OS level에서의 작업이 빈번함
    • thread 하나가 task 하나를 담당하고 있기 때문에, task를 어떻게 쪼갤지(thread를 어떻게 쪼개서 사용할지)를 OS level에서 정해줘야한다, Preempting Scheduling.
    • thread를 이동하며 concurrency를 성취하기 때문에, OS kernel을 이용한 Context Switching이 자주 발생한다.
  • Blocking 발생시, thread자체의 block을 의미함. 하나의 task를 blocking하고 다른 task가 수행되기까지 기다려야한다는 것은, 하나의 thread의 사용을 중지하고 새로운 thread를 운영하는 것과 같은 의미이다.

Coroutine - Coroutine의 효율성은 프로그래머의 몫이다.

  • task의 단위 = Object
    • 다수의 작업 각각에 Object를 할당함.
    • task는 heap의 영역을 차지함
  • OS level에서의 작업은 선택사항
    • thread는 heap영역의 Object를 처리하는 도구라고 보며, task를 어떻게 쪼갤지는 programmer가 정하면 된다.
    • 서로 다른 object(task)가 단일 thread에서 수행된다면, concurrency 성취를 위한 task전환은 단일 thread에서 벌어지기 때문에, context switching은 발생하지 않는다.

출처 | Coroutine, Thread 와의 차이와 그 특징

JS에서의 Coroutine

js에서는 generator와 yield를 이용해서 coroutine을 수행한다.

  • GeneratorYield
    Generator : 함수가 특정 지점(yield)에서 끝나고 다음 실행 때(Generator.next 호출)에는 끝난 시점에서 다시 시작하게 하는 것
    Generator 함수를 호출한다 = iterator를 지정한다.
    yield = return과 비슷
  1. yield - generator
    순수한 yield는 상수를 취급하는게 맞다.
function* call() {
	console.log('first call');
	yield 10;
   	console.log('second call');
    	yield 20;
    	console.log('third call');
	yield 30;
}
        
let gen = call(); // iterator를 지정한다. standby상태
console.log(gen.next()); //첫번째 yield까지 수행한다. 
console.log(gen.next());
console.log(gen.next()); // 아직 call함수의 return이 시행되지 않았기에 done은 false
console.log(gen.next()); // yield값은 없지만 함수는 return되었으므로, done은 true
first call
{ value: 10, done: false }
second call
{ value: 20, done: false }
third call
{ value: 30, done: false }
{ value: undefined, done: true }
  1. yield* - generator

iterator가 iterator를 호출하는 경우에는 yield*사용해준다.

yield값이 generator인 경우에 **generator의 의미로서,
해당 generator의 yield를 수행한다.

function* func(){
	yield 42;
}
        
function* option1(){
	yield func(); // func의 iterator를 yield한다.
}
function* option2(){
	yield func; // func함수 자체를 yield한다.
}
function* option3(){ 
	yield* func(); // func함수의 iterator를 수행하여 func의 yield까지 수행한다.
}
        
console.log('option 1: ', option1().next());
console.log('option 2: ', option2().next());
console.log('option 3: ', option3().next());
option 1:  { value: Object [Generator] {}, done: false }
option 2:  { value: [GeneratorFunction: func1], done: false }
option 3:  { value: 42, done: false }
  1. yield - 취급값 없음

    ...

출처 | [JavaScript-16]Generator와 Yield



☄️ JS-coroutine에 관하여,

3가지 핵심 Functionality

Process in parallel

보통, huge process들을 처리하기 위해서 worker thread가 사용된다. worker thread를 이용해서 데이터가 이동하는 것은 결함을 야기할 수 있다.

js-coroutine의 context switching 대안책

Collaborative multitasking를 통해 main thread를 sharing한다. (coroutine 개념, thread쪼개기)

  • async 함수 안에서 js-coroutines의 모듈을 이용해서 사용가능하다
  • js-coroutines는 해당 코드를 main thread의 idle time(CPU가 쉬는 시간)에 처리한다.
  • 해당 작업을 제외한 나머지를 다 분배하고 난 후 남은 시간에 huge process를 실행한다.

Coroutine에 우선 순위를 두는 것! huge process는 후순위가 됨

⇒ Context switching은 일어나지 않음! main Thread만 사용

Imperative Animation (명령형 animation)

*imperative; 어떻게 동작하는지에 관한 statements에 초점을 둔다. (C, C++, JAVA ...)

*declarative; 내부 동작 원리 보다는 논리 자체에 초점을 둔다. (HTML, CSS, ...)

  • ex) 로봇을 옆건물까지 보내야할 때 —> imperative : 문 밖을 나간다 - 몇층인지 확인한다 - ... -옆 건물로 이동한다. —> declarative : 옆건물로 간다.

CSS로 animation구현하기

css declarative 언어이기 때문에(loop나 이런거 못씀)

requestAnimationFrame을 사용해서 multiple frame을 사용할 수는 있지만, 점점 코드는 난해해져간다.

js-coroutine의 declarative animation 대안책

generator function을 이용해서 복잡한 애니메이션 논리가 구성되는 다음 frame을 기다려야할 때, 그 시간을 채울 수 있도록 할 수 있다!

Create Own Coroutines in Idle Time

js-coroutines를 사용하면, generator function을 이용해서 collaborative multitasking을 수행가능하다.

어쨌든 generator를 사용해서 iterator를 만드는 것은 coroutine을 이용하겠다는 의미!

  • js-coroutines의 controlling time check ( 특정 frame이 처리되기 위한 시간을 조절하는 법 )

    • yield standard time check, 내부적으로 idle time이 대략 충분하게 남아있는지 확인하는 작업 js의 yield와 의미는 비슷하지만 사용법은 다르다
    • yield n 특정 frame을 idle time에서 처리하기 위한 n m/s가 남아있는지 확인
    • yield true current work는 this frame을 중단하고, next idle에서 처리하도록!
    • result = yield new Promise(...) yield any promises이며, coroutine은 promises가 완료될때 까지 suspended되고 완료 되고 return을 받은 후 coroutine은 다시 시작된다.
    • result = yield * subFunction() 또다른 coroutine을 수행하지만, 해당 루틴의 결과값으로 소통할 필요가 없을때 사용
  • js-coroutine은 어쨌든 generator.next()를 내부적으로 idle타임에서 수행해준다!

    사용자가 할 일은, 그냥 load가 큰 함수를 넣어주고, yield할 위치만 정하면 됨!

coroutine을 어떻게 생성할까?

coroutine은 main thread의 logical processing state machine이다.

👩‍💻 코드 분석

skeleton code

Promise와 requestIdleCallback이 main concept

export async function run(coroutine, loopWhileMsRemains=1, timeout){};

coroutine

generator | Iterator

coroutine으로서 수행될 task를 의미한다.

loopWhileMsRemains

current frame의 idle time이 해당 시간보다 적으면 next idle frame에서 coroutine이 수행됨

timeout

system이 Idle time에 있지 않다면, task를 수행할 시간.

let terminated = false;
let resolver = null;

terminated

task의 종료 여부를 결정하는 변수

resolver

task의 반환 함수(resolve)를 담는 변수

const result = new Promise(function(resolve, reject){});

promise

promise를 통해 비동기로 수행되는 작업의 마지막 end point를 알 수 있게 해준다.

result.terminate = function(result) {
	terminated = true;
	if(resolver){
		resolver(result)
	}
}

수행중이던 coroutine을 멈추고, 수행 중이던 결과까지를 반환함!

coroutine in idle time 작동 원리 (Promise 함수 분석)

generator/yield와 resolve를 통해서 Idle Time coroutine을 실행함

resolver = resolve;
const iterator = coroutine.next ? coroutine : coroutine();

iterator

coroutine의 Iterator가 생성되었는지 확인 후, 생성되지 않았으면 생성. 아니면(이미 coroutine은 iterator임.) 그대로 사용함.

request(run)

// request
request = typeof window === 'undefined' 
					? getNodeCallback() // requestIdleCallback 대체함수. 거의 사용 안함
					: window.requestIdleCallback;

window.requestIdleCallback(callback)

전달받은 callback을 browser의 idle period에서 호출할 수 있도록 queue해놓는 method

background 작업 수행과, main event loop에서 priority에 맞게 작업을 수행할 수 있도록 함.

해당 함수가 호출되면, callback으로 IdleDeadlineobject를 넘려준다.

IdleDeadline

IdleDeadline.timeRemaining

current idle period에서 남은 ms를 반환한다.

let id = 0;
let parameter = undefined; // coroutine안에 coroutine(Promiese)의 경우, 그 값을 담는 변수
let running = false; // background에서 수행중인 task의 유무. true이면 pending

async function run(api){
	if(running) return;
	try{
		running = true; // 현재 끝나지 않은 coroutine이 있음을 의미
		clearTimeout(id) // 이전에 timeout 변수에 맞게 scheduling되어있던 setTimeout 제거 ~ 지금 수행할거니까!
		
		if(terminated){ // 외부 종료 명령에 따라서 coroutine을 수행하지 않음
			iterator.return()
			return
		}
		let minTime = Math.max(minRemainingTime = 1.75, loopWhileMsRemains);
		
		try{
			// 프로그램 상 남은 idle time이 설정치보다 커야 계속 작업 수행함.
			while(api.timeRemaining() > minTime) { 
				const {value, done} = iterator.next(await parameter)
				parameter = undefined; // 보통은 다음 coroutine을 위한 parameter는 필요하지 않으므로 undefined로 정의
				
				if(done){ // coroutine 작업이 다 끝났으므로 결과값을 반환하기!
					resolve(value);
					return;
				}
				if(value === true){ // yield done의 경우 coroutine을 중지하고, next idle에서 수행할 수 있도록 함
					break;
				} else if (typeof value === 'number'){ // value만큼의 idleTime이 남았는지!
					minTime = +value; // value가 number인지 한번 더 확인 후, minTime을 value로 
					if(isNaN(minTime)) minTime = minRemainingTime
				} else if (value && value.then) { // value가 Promise인 경우!
						parameter = value;
				}
			}
		} catch (e) {
				console.log('error: ', e);
				reject(e);
				return;
		}
		// 끝날때까지 계속 수행
		request(run);
		if(timeout){
			id = setTimeout(runFromTimeout, timeout);
		}
	} finally {
		// coroutine의 작업이 모두 종료되었음을 의미함
		running = false;
	}
}

minTime

programmer가 지정한 최소 Idle time과 권장되는 최소 Idle Time중 더 큰 것으로 default를 가진다.

iterator.next(await parameter)

yield가 가지는 값이 또다른 coroutine(Promise)인 경우를 위해서 await parameter로 전달됨

react-native iOS문제

react-native ios에서 js-coroutine의 run함수가 아예 실행이 안됨

그거는 window.requestIdleCallback 의 문제!!!

아래 글을 보고 requestIdleCallback을 바꿔줬더니 잘~돌아감

https://github.com/facebook/react-native/issues/28602

  • requestIdleCallback 오류시 해결법 ~ 직접 선언해서 사용
window.requestIdleCallback = function (cb) {
	var start = Date.now();
	return setTimeout(function () {
		cb({
			didTimeout: false,
			timeRemaining: function () {
				return Math.max(0, 50 - (Date.now() - start));
			},
		});
	}, 1);
	};
  • RN coroutine 예시 코드
    import React, {useRef, useState} from 'react';
    import {useEffect} from 'react';
    import {memo} from 'react';
    import {Dimensions, Image, View, Animated} from 'react-native';
    // import {
    //   append,
    //   forEach,
    //   map,
    //   reduce,
    //   run,
    //   singleton,
    //   update,
    //   yielding,
    // } from 'js-coroutines';
    import run from '../utils/run';
    
    import Typography from '../components/atoms/Typography';
    import {append, forEach, map, reduce, update, yielding} from 'js-coroutines';
    const image = require('../assets/images/app_logo.png');
    const width = 100;
    function* coroutine1() {
      console.log('in coroutine!');
      let results;
      results = new Array(2000000);
      for (let i = 0; i < 2000000; i++) {
        if ((i & 127) === 0) yield;
        results[i] = (Math.random() * 10000) | 0;
      }
    }
    
    async function createAsync() {
      console.log('createAsync!');
      await run(coroutine1);
    }
    
    const CoroutineTest: React.FC<{}> = ({}) => {
      const [x, setX] = useState(0);
      const [y, setY] = useState(0);
      const [text, setText] = useState('first');
      const {format} = new Intl.NumberFormat();
    
      function animate() {
        console.log('-animate-');
        let multiplier = Dimensions.get('window').width / 300;
        return update(function* () {
          while (true) {
            //Move left to right
    
            //Move top to bottom
            for (let y = 0; y < 200; y++) {
              setY(y * multiplier);
              yield;
            }
            for (let y = 200; y >= 0; y--) {
              setY(y * multiplier);
              yield;
            }
          }
        });
      }
      async function calculateAsync() {
        return await run(function* () {
          let results;
    
          //Create 2 million rows of random values
          results = new Array(2000000);
          for (let i = 0; i < 2000000; i++) {
            if ((i & 127) === 0) {
              yield;
            }
            results[i] = (Math.random() * 10000) | 0;
          }
          setText(`CO: Created ${format(results.length)} items`);
    
          //Double all the values
          yield* forEach(
            results,
            yielding((r, i) => (results[i] = r * 2)),
          );
          setText(`CO: Doubled the value of ${format(results.length)} items`);
    
          //Get the square roots
          const sqrRoot = yield* map(
            results,
            yielding((r) => Math.sqrt(r)),
          );
          setText(
            `CO: Created a new array with the square roots of ${format(
              sqrRoot.length,
            )} items`,
          );
    
          //Sum all of the items
          setText(
            `CO: Sum of ${format(results.length)} items is ${format(
              yield* reduce(
                results,
                yielding((c, a) => c + a, 64),
                0,
              ),
            )}`,
          );
    
          //Join the arrays
          yield* append(results, sqrRoot);
          setText(
            `CO: Appended the square roots to the normal values making ${format(
              results.length,
            )} items in the array`,
          );
    
          // Sort the results
          yield* sort(results, (a, b) => a - b);
          setText(`CO: Sorted ${format(results.length)} items`);
          return results;
        });
      }
      useEffect(() => {
        animate();
        calculateAsync().then((r) => {
          console.log('calculation done: ', r);
        });
        createAsync().then((v) => {
          console.log('done!');
        });
      }, []);
      return (
        <View
          style={{
            width: '100%',
            height: '100%',
            alignItems: 'center',
            paddingTop: 100,
            borderWidth: 10,
          }}>
          <Animated.View
            style={{
              height: width * 0.8,
              width: width * 0.8,
              backgroundColor: 'green',
              borderRadius: width * 0.8,
              borderWidth: 10,
              borderColor: 'blue',
              transform: [
                {
                  translateY: y,
                },
              ],
            }}
          />
          <Typography>{text}</Typography>
        </View>
      );
    };
    
    export default memo(CoroutineTest);
    function* sort(results: any, arg1: (a: any, b: any) => number) {
      throw new Error('Function not implemented.');
    }

결론, 나쁘지 않다.

JS에서 실현할 수 있는 coroutine(generator & yield)은 프로그래머의 선택에 따른다.

그 말인 즉, thread를 얼마나 사용하고 어떤 thread에서 어떤 task를 수행할지도 다 정해야한다!

또한, thread가 어떤 time period에서 사용되는지도 선택해야한다는 것!

js-coroutine은 로드가 큰 작업들은 완전히 background에서 수행될 수 있도록,

idle time을 내부적으로 체크해서 해당 period에서 수행되도록 기능을 제공한다.

또한, main Thread만 사용하므로 context switching도 발생하지 않는다.



아래는 JS-coroutine에서 핵심적으로 사용된 background Tasks API에 관한 포스트이다.

📲 Background Tasks API - requestIdleCallback()

= cooperative Scheduling of Background Tasks API는 해당 작업을 수행할 free time이 존재하면 자동으로 수행될 수 있도록 queuing tasks를 제공한다.

JS-coroutine은 background라는 개념을 사용했고, requestIdleCallback을 통해서 해당 개념을 수행한다.

Concepts

requestIdleCallback을 이용해서 system lag 없이 event loop를 수행할 수 있는 시간을 파악하고, 수행한다.

Idle Callbacks를 최대한 활용하는 방법

  • low priority를 갖는 task를 idle callback으로 처리하기
    보통은 callback이 얼마나 수행될지(timeout을 설정하지 않으면), user의 system이 어떤 상태인지 등을 모르기 때문에 매 frame마다 idle time이 존재하는지 알 수 없다.

    그렇기 때문에 최대한 low priority의 작업을 수행하는 것이 좋다.

  • idle callback은 할당된 시간을 초과하지 않도록!
    timelimit은 작업이 종료할 시간을 충분히 확보하기 위해서 만들어 진 것이다.
    timeRemaining()이 50ms까지 리밋이 걸려있긴 하지만, 실제로는 여러가지 다른 복잡한 요인들로 50ms보다 적은 시간이 남아있을 수 있다.

    그렇기 때문에 할당도 너무 빡빡하게 하지말도록 하고, 최대한 할당된 시간 안에서 처리될 수 있도록!

  • idle callback안에서 making changes to DOM은 피하도록!
    callback이 수행될때는, 이미 current frame은 할 일을 끝낸 상태!
    그렇기 때문에 여기에서 추가적인 변경은 지양해야한다.

    만약, idleTime에서의 changes to DOM이 필요하면 window.requestAnimationFrame()을 사용하도록! ~ JS-coroutine의 update도 이 개념을 그대로 사용하고 있다.

  • run time이 예측되지 않는 task는 지양하자!
    callback에서 아래의 작업들은 사용하지 말도록!
    -layout을 건드리는 task
    - Promise를 return하는 callback (비동기는 예측불허니!)
  • timeout(main loop에서 남은 시간 정하기)는 진짜 필요할때만!
    원래는 idle time에 queue되어있는 task들이 FIFO의 순서대로 처리되는데,
    timeout 옵션을 줘버리면 queue되어 있는 순서에 상관없이, timeout 시간동안 해당 calback(task)가 수행이 안되면, 그냥 먼저 실행시켜버리는 옵션이다

setTimeout으로 Background Tasks API 호환성 보장하기

이 부분이 아래 RN에서 requestIdleCallback을 지원하지 않는 문제 해결 가능!!

window.requestIdleCallback = window.requestIdleCallback || function(handler) {
  let startTime = Date.now();

  return setTimeout(function() {
    handler({
      didTimeout: false,
      timeRemaining: function() {
        return Math.max(0, 50.0 - (Date.now() - startTime));
      }
    });
  }, 1);
}

Background Tasks API 사용해보기

Background Tasks API - Example - code sample

페이지 소스 보기로 예시 코드 볼 수 있음!

Background Tasks API - Web APIs | MDN

출처 | requestIdleCallback 코드 설명

requestIdleCallback이 호출되면, 호출되는 순서대로 callback이 queue되어서 시스템 내부적으로 FIFO 순서에 맞게 각 callback이 idleTime에 맞게 알아서들 호출되지만,

개발자의 입장에서 시스템의 내부에 맡기는 것이 아니라 직접 task 수행 실행/완료를 control하는 것이 더 맞기 때문에, taskList 변수를 이용해서 queue를 명시적으로 수행해준다.

결론, 다양한 조합으로 background task handling이 가능하다.

위의 예시를 조금만 뜯어서 살펴보면

requestIdleCallback과 queue개념을 이용해서 load가 큰 task를 background에서 효율적으로 사용했음을 알 수 있다.

수행중인 task가 끝나기 전에 새로운 task에 대한 사용자 요청이 생기더라도

task는 queue에 accumulate되기 때문에 FIFO 순서대로 실행된다.

사용방식에 따라서 효율적으로 background task를 수행할 수 있을 것 같다.



💡 JS-coroutine을 사용해야만 할까?

답을 하기전, 지금까지 소개했던 개념들의 차이를 정확하게 알아야할 것 같다.

Coroutine

task와 thread를 1:1로 대응하는 방식이 아닌, N:1로 대응하는 방식이다.

선택에 따라서 1개 이상의 thread를 사용할 수 있으며, thrad에서 수행될 task들도 지정할 수 있다.

JS에서는 coroutine을 generator와 yield를 통해 수행할 수 있다.


RequestIdleCallback

함수 자체는, mainThread의 idle time에서 task(callback)를 수행할 수 있도록 해준다.

callback으로 받은 task는 idle period에 실행될 수 있도록 시스템 자체에서 queue되며, 언젠가 앞에서 먼저 queue되어있던 task가 끝나면, 다음 task가 실행되는 구조를 갖고 있다.

위의 Background Tasks API 사용해보기의 코드를 살펴보면,

queue 개념을 명시적으로 사용하므로써 pending되어있는 task들을 끝까지 처리해주는 것을 알 수 있다.

사용자의 요청에 따라 거대한 task를 계속해서 accumulating하고, 해당 task를 중단할 필요가 없이 순차적으로 수행하기만 하면 된다면 직접 queue logic을 설계하여 사용하는 것도 좋은 방법이다.


JS-coroutine

mainThread만을 사용해서 개별 task들을 background에서 처리한다.

coroutine과 requestIdleCallback을 통합한 개념이라고 볼 수있다.

JS-coroutine은 task를 handling할 수 있는 옵션을 제공한다.

yield | yield n | yield true | yield generator | yield Promise 와 같은 옵션을 통해서

하나의 huge task를 쪼개어서(iterator) requestIdleTime의 callback에 넣어서 task 수행을 진행한다.

하나의 거대한 task를 쪼개야한다면, coroutine + requestIdleCallback 조합을 사용하는 건 좋은 방법이다.

결론

up to you

profile
잘 하고 싶은 욕심을 가득 갖고 태어남

0개의 댓글