ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 이벤트 루프?
    프로그래밍/Node 2021. 7. 4. 17:11
    Node를 공부하면서, 중요한 개념인 이벤트 루프를 공부 해 보고자 한다.
    내가 선택한 방법은 무식하지만 그냥 node doc문서를 다 해석하는 것.

     

    1. 이벤트 루프는 무엇인가?

    자바스크립트가 싱글 스레드임에도, Node가 non-blocking I/O 동작을 수행하도록 도와주는 프로그래밍 구조체 이다.

     

    대부분의 최신 커널이 멀티 스레드이므로, 백그라운드에서 실행되는 여러 작업을 실행할 수 있다. 하나의 작업이 완료가 되면, 커널은 Node에게 알려준다. Node에게 알려 준 callback은 poll 큐에 추가 되고,  결국에는 실행 된다. 뒤에서 좀 더 자세하게 설명하겠다.

     

    2. 이벤트 루프 설명

    Node.js가 시작되면 이벤트 루프를 초기화하고 제공된 입력 스크립트를 처리 (또는이 문서에서 다루지 않는 REPL로 드롭)하여 비동기 API 호출, 스케줄 타이머 또는 process.nextTick () 호출을 수행 할 수 있다. 그런 다음 이벤트 루프 처리를 시작한다.

     

    다음에 나오는 그림은 이벤트 루프의 작동순서를 간략히 요약한 것이다.

    각각의 박스는 이벤트 루프의 "phase"(단락)라고 한다.

    각 단락은 실행할 콜백이 들어 있는 '선입선출' 큐를 가지고 있다. 각 단계는 고유 한 방식으로 특별하지만 일반적으로 이벤트 루프가 주어진 단계에 진입하면 해당 단계에 특정한 작업을 수행 한 큐가 비거나 시스템의 한도 초과에 도달 할 때까지 해당 단계의 대기열에서 콜백을 실행한다.  큐가 비거나 시스템의 한도 초과에 도달 할 때, 이벤트 루프는 다음 단계로 넘어간다.


    이러한 단계 중 하나가 더 많은 작업을 예약 할 수 있고 폴 단계에서 처리 된 새 이벤트가 커널에 의해 대기열에 있으므로 폴링 이벤트가 처리되는 동안 폴 이벤트가 대기열에 추가 될 수 있다. 결과적으로 시간이 오래 걸리는 콜백은 폴 단계가 타이머의 임계 값보다 훨씬 오래 실행되도록 할 수 있다. 타이머와 poll 섹션에서 자세한 내용을 살펴보자.

     

    Windows와 Unix / Linux 구현간에 약간의 차이가 있을 수도 있지만, 중요하지 않다.  실제로 단계가 7~8단계가 있지만, 우리가 신경 쓰는 단계는 Node.js가 실제로 사용하는 단계이다.

     

    단계 요약

    • timers: 이 단계는 setTimeout() 과 setInterval()에 의해 예약된 callback을 실행한다.
    • pending callbacks: 루프 반복으로 지연된 I/O 콜백을 실행한다.
    • idle, prepare: 내부적으로만 사용.
    • poll: 대부분의 콜백과 관련된 I/O을 실행한다.(close callback, timers, setImmediate()를 제외)
    • check: 여기서 setImmedate() 콜백을 호출한다.
    • close callbacks: 몇몇의 close callback을 호출한다. 예를 들어, socket.on('close')

    이벤트 루프가 실행되면서, Node는 비동기 I/O 또는 대기하고 있는 타이머가 있는 지 확인하고, 없는 경우

    종료한다.

     

    상세내용

    timers

    timers는 사용자가 실행하기를 원하는 정확한 시간이 아니라 콜백이 실행될 수있는 임계 값을 지정한다.

    timers 콜백은 지정된 시간이 지난 후 될 수 있는 한 빨리 실행된다. 그러나 다른 콜백 실행으로 인해 지연 될 수 있다.

     

    기술적으로, poll 단계에서 timers가 실행되는 시기를 통제한다.

    예를 들어, 100ms 임계 값 이후에 실행되도록 제한 시간을 예약하고, 스크립트가 95ms가 걸리는 파일을 비동기 적으로 읽기 시작한다고 가정 해 보자

     

    const fs = require('fs');
    
    function someAsyncOperation(callback) {
      // Assume this takes 95ms to complete
      fs.readFile('/path/to/file', callback);
    }
    
    const timeoutScheduled = Date.now();
    
    setTimeout(() => {
      const delay = Date.now() - timeoutScheduled;
    
      console.log(`${delay}ms have passed since I was scheduled`);
    }, 100);
    
    // do someAsyncOperation which takes 95 ms to complete
    someAsyncOperation(() => {
      const startCallback = Date.now();
    
      // do something that will take 10ms...
      while (Date.now() - startCallback < 10) {
        // do nothing
      }
    });​

    이벤트 루프가 폴 단계에 진입하면 큐가 비어 있으므로 (fs.readFile ()이 완료되지 않음) 가장 빠른 타이머 임계 값에 도달 할 때까지 남은 ms 수를 기다린다.

    95ms 통과를 기다리는 동안 fs.readFile ()은 파일 읽기를 마치고 완료하는 데 10ms가 걸리는 콜백이 폴 큐에 추가되고 실행된다.

    콜백이 완료되면 대기열에 더 이상 콜백이 없으므로 이벤트 루프는 가장 빠른 타이머의 임계 값에 도달 한 것을 확인한 다음 타이머 단계로 돌아가 타이머의 콜백을 실행한다.

    이 예에서는 예약된 timer와 실행되는 콜백 사이의 총 지연이 105ms임을 알 수 있다.

     

    폴링 단계에서 이벤트 루프가 고갈되는 것을 방지하기 위해 libuv (Node.js 이벤트 루프 및 플랫폼의 모든 비동기 동작을 구현하는 C 라이브러리)는 더 많은 이벤트에 대한 폴링을 중지하기 전에 한계 값(시스템마다 다름)을 지정 해 둔다.

    pending callbacks

    이 단계는 TCP 오류 유형과 같은 일부 시스템 작업에 대한 콜백을 실행한다. 예를 들어, TCP 소켓이 연결을 시도 할 때 ECONNREFUSED를 수신하면 일부 * nix 시스템은 오류보고를 기다리려고 한다. 대기중인 콜백 단계에서 실행되도록 대기열에 추가된다.

     

    poll

    poll 단계에는 두 가지의 메인 기능이 있다.

    1. I / O를 차단하고 폴링해야하는 기간을 계산 한 다음

    2. 폴 큐에서 이벤트 처리한다.

     


    이벤트 루프가 폴 단계에 들어가고 예약 된 타이머가 없으면 다음 두 가지 중 하나가 발생한다.

    • 폴 큐가 비어 있지 않은 경우 이벤트 루프는 큐가 소진되거나 시스템이 한계에 도달 할 때까지 동기적으로 실행하는 콜백 큐를 반복한다.
    • 폴 큐가 비어 있으면 다음 두 가지 중 하나가 더 발생한다.
      • 스크립트에서 setImmediate ()에 의해 예약 된 경우 이벤트 루프는 폴 단계를 종료하고 예약 된 스크립트를 실행하기 위해 check 단계로 넘어 간다.
      • setImmediate ()에 의해 스크립트가 예약되지 않은 경우, 이벤트 루프는 콜백이 대기열에 추가 될 때까지 기다린 다음 즉시 실행한다.

    폴 큐가 비어 있으면 이벤트 루프는 시간 임계 값에 도달 한 타이머를 확인한다.. 하나 이상의 타이머가 준비된 경우 이벤트 루프는 타이머 단계로 다시 래핑되어 해당 타이머의 콜백을 실행한다.

     

    check

    이 단계에서는 폴 단계가 완료된 후 즉시 콜백을 실행할 수 있다. 폴 단계가 유휴 상태가되고 스크립트가 setImmediate ()로 대기열에있는 경우 이벤트 루프는 대기하지 않고 확인 단계로 넘어간다.

    setImmediate ()는 실제로 이벤트 루프의 별도 단계에서 실행되는 특수 타이머이다.

    폴링 단계가 완료된 후 실행할 콜백을 지정하기 위해 libuv API를 사용한다. 일반적으로 코드가 실행될 때 이벤트 루프는 결국 수신 연결, 요청 등을 기다리는 폴 단계에 도달한다. 그러나 콜백이 setImmediate ()로 지정되고 폴 단계가 유휴 상태가 되면 폴 이벤트를 기다리지 않고 check 단계에 계속 있는다.

     

    close callbacks

    소켓 또는 핸들이 갑자기 닫히면 (예 : socket.destroy ()) 이 단계에서 'close'이벤트가 발생한다. 그렇지 않으면 process.nextTick()을 통해 내보낸다.

     

     

    setImmediate() vs setTimeout()

    setImmediate () 및 setTimeout ()은 비슷하지만 호출시기에 따라 다른 방식으로 작동한다.

    • setImmediate ()는 현재 폴 단계가 완료되면 스크립트를 실행하도록 설계 되었다.
    • setTimeout ()은 ms 단위의 최소 임계 값이 경과 한 후 스크립트가 실행되도록 설계 되었다.


    타이머가 실행되는 순서는 호출되는 컨텍스트에 따라 다르다.

    둘 다 메인 모듈 내에서 호출되는 경우 타이밍은 프로세스의 성능에 의해 결정된다. (머신에서 실행중인 다른 응용 프로그램의 영향을받을 수 있음). 

     

    예를 들어, I / O주기 (즉, 메인 모듈) 내에 있지 않은 다음 스크립트를 실행하는 경우 두 타이머가 실행되는 순서는 알 수없다. 프로세스 성능에 따라 결정 된다.

    // timeout_vs_immediate.js
    setTimeout(() => {
      console.log('timeout');
    }, 0);
    
    setImmediate(() => {
      console.log('immediate');
    });​

     

    그러나, I / O주기 내에서 호출을 하면, Immediate 콜백이 항상 먼저 실행된다.

    const fs = require('fs');
    
    fs.readFile(__filename, () => {
      setTimeout(() => {
        console.log('timeout');
      }, 0);
      setImmediate(() => {
        console.log('immediate');
      });
    });​

    setTimeout ()보다 setImmediate ()를 사용할 때의 주요 이점은 I / O주기 내에서 지정 된 경우

    setImmediate ()가  타이머 수에 관계없이 timer보다 항상 먼저 실행 된다.

     

     

    process.nextTick()

    process.nextTick ()이 비동기 API의 일부 임에도 불구하고 다이어그램에 표시되지 않았다.

    이유는 process.nextTick ()이 기술적으로 이벤트 루프의 일부가 아니기 때문이다. 대신 이벤트 루프의 현재 단계에 관계없이 현재 작업이 완료된 후 nextTickQueue가 처리된다. 

    여기서 작업은 기본 C / C ++ 처리기에서 전환하고, 실행해야하는 JavaScript를 처리하는 것이다.

    다이어그램을 돌아 보면 주어진 단계에서 process.nextTick ()을 호출 할 때마다 process.nextTick ()에 전달 된 모든 콜백은 이벤트 루프가 계속되기 전에 해결된다.

    이는 이벤트 루프가 폴 단계에 도달하는 것을 방지하는 재귀 process.nextTick () 호출을 수행하여 I / O를 "고갈"시킬 수 있기 때문에 몇 가지 나쁜 상황을 만들 수 있다.

     

     

    Why would that be allowed?

    왜 이와 같은 것이 Node.js에 포함되나? 이것은 API가 반드시 있어야 할 필요가없는 곳에서도 항상 비동기 적이어야한다는 설계 철학이다. 다음 코드 스니펫을 보자.

    function apiCall(arg, callback) {
      if (typeof arg !== 'string')
        return process.nextTick(callback,
                                new TypeError('argument should be string'));
    }​

    위 스 니펫을 보면 arg를 체크하고, 올바르지 않은 경우 콜백에 오류를 전달합니다.

    이 API는 인수를 process.nextTick ()에 전달할 수 있도록 최근에 업데이트되어 콜백 이후에 전달 된 모든 인수를 콜백에 대한 인수로 전파 할 수 있으므로 함수를 중첩 할 필요가 없다.

    이것을 통해 사용자에게 오류를 다시 전달하는 것이지만, 나머지 사용자 코드가 실행되도록 허용 한 후에 만 ​​가능하다.

    이를 위해 JS 호출 스택은 RangeError에 도달하지 않고 process.nextTick ()에 대한 재귀 호출을 수행 할 수 있도록 제공된 콜백을 즉시 해제 할 수 있다.

     

    잠재적으로 문제가되는 상황을 살펴보자. 다음 스니펫을 보자.

    let bar;
    
    // this has an asynchronous signature, but calls callback synchronously
    function someAsyncApiCall(callback) { callback(); }
    
    // the callback is called before `someAsyncApiCall` completes.
    someAsyncApiCall(() => {
      // since someAsyncApiCall hasn't completed, bar hasn't been assigned any value
      console.log('bar', bar); // undefined
    });
    
    bar = 1;​

    사용자는 비동기적으로 작동하도록 someAsyncApiCall ()을 정의하지만 실제로는 동기적으로 작동한다. 호출 될 때 someAsyncApiCall ()에 제공된 콜백은 이벤트 루프의 동일한 단계에서 호출된다. someAsyncApiCall ()은 실제로 비동기 적으로 아무것도 수행하지 않기 때문이다. 결과적으로 콜백은 스크립트가 완료 될 때까지 실행할 수 없기 때문에 scope에 해당 변수가 아직 없을지라도 bar를 참조하려고 한다.

     

    process.nextTick ()에 콜백을 배치하면 스크립트는 여전히 완료 될 때까지 실행할 수 있으며 콜백이 호출되기 전에 모든 변수, 함수 등을 초기화 할 수 있다. 또한 이벤트 루프가 계속되지 않도록하는 이점도 있다. 이벤트 루프가 계속되기 전에 사용자가 오류에 대한 경고를받는 것이 유용 할 수 있다. 다음은 process.nextTick ()을 사용한 이전 예제이다.

     

    let bar;
    
    // this has an asynchronous signature, but calls callback synchronously
    function someAsyncApiCall(callback) { callback(); }
    
    // the callback is called before `someAsyncApiCall` completes.
    someAsyncApiCall(() => {
      // since someAsyncApiCall hasn't completed, bar hasn't been assigned any value
      console.log('bar', bar); // undefined
    });
    
    bar = 1;​

     

    다음은 또 다른 실제 사례이다.

    const server = net.createServer(() => {}).listen(8080);
    
    server.on('listening', () => {});

    포트만 전달되면 포트가 즉시 바인딩된다. 따라서 'listening' 콜백을 즉시 호출 할 수 있다.. 문제는 .on ( 'listening') 콜백에 해당 시간까지 설정되지 않는다는 것이다. 이 문제를 해결하기 위해 'listening'이벤트가 nextTick ()에 큐에 추가되는 것이다. 그것은 스크립트가 완료 될 때까지 실행 된다. 이를 통해 사용자는 원하는 이벤트 핸들러를 설정할 수 있다.

     

     

    process.nextTick() vs setImmediate()

     유사한 두 가지 호출인데, 이름이 매우 혼란스럽다.

    • process.nextTick ()은 동일한 단계에서 즉시 실행된다.
    • setImmediate ()는 이벤트 루프의 다음 반복 또는 '틱'에서 발생한다.

    본질적으로 이름을 바꿔야 한다. setImmediate ()보다는  process.nextTick ()이 즉시 실행이라는 이름과 어울리는 듯하다. 하지만, 이름을 바꾸면 npm에서 많은 비율의 패키지가 중단된다. 또한 매일 더 많은 새로운 모듈이 추가되고 있다. 즉,  더 많은 잠재적 인 에러가 발생 할 수 있다. 혼란 스럽지만 이름 자체는 변경되지 않는다.

    추론하기 쉽기 때문에 개발자는 모든 경우에 setImmediate ()를 사용하는 것을 추천한다.

     

    Why use process.nextTick()?

    두 가지 이유가 있다.

    1. 사용자가 오류를 처리하고, 불필요한 리소스를 정리하거나, 이벤트 루프가 계속되기 전에 요청을 다시 시도 할 수 있다.
    2. 호출 스택이 풀린 후 이벤트 루프가 계속되기 전에 콜백이 실행되도록 허용 해야하는 경우가 있다.

     

    간단한 예를 살펴보자.

    const server = net.createServer();
    server.on('connection', (conn) => { });
    
    server.listen(8080);
    server.on('listening', () => { });

    listen ()은 이벤트 루프의 시작 부분에서 실행되지만 listening 콜백은 setImmediate ()에 배치된다. hosname이 전달되지 않으면 즉시 포트 바인딩된다.. 

     

    이벤트 루프가 진행 되려면 폴 단계에 도달해야 하는데, listening 이벤트가 발생하기 전에 connection 이벤트가 되었을 가능성이 0이 아닐 수 있다.

     

    또 다른 예는 EventEmitter에서 상속하고 생성자 내에서 이벤트를 호출하고자하는 함수 생성자를 실행하는 것이다.

    const EventEmitter = require('events');
    const util = require('util');
    
    function MyEmitter() {
      EventEmitter.call(this);
      this.emit('event');
    }
    util.inherits(MyEmitter, EventEmitter);
    
    const myEmitter = new MyEmitter();
    myEmitter.on('event', () => {
      console.log('an event occurred!');
    });

    사용자가 해당 이벤트에 콜백을 할당하는 지점까지 스크립트가 처리되지 않기 때문에 생성자에서 이벤트를 즉시 생성 할 수 없다. 따라서 생성자 자체 내에서 process.nextTick ()을 사용하여 생성자가 완료된 후 이벤트를 방출하도록 콜백을 설정하여 예상 결과를 제공 할 수 있다.

     

    const EventEmitter = require('events');
    const util = require('util');
    
    function MyEmitter() {
      EventEmitter.call(this);
    
      // use nextTick to emit the event once a handler is assigned
      process.nextTick(() => {
        this.emit('event');
      });
    }
    util.inherits(MyEmitter, EventEmitter);
    
    const myEmitter = new MyEmitter();
    myEmitter.on('event', () => {
      console.log('an event occurred!');
    });

     


    느낀점

    너무 어렵다.

    영어를 해석하는 것도 어려웠고,

    event loop를 이해하는 것도 어려웠다.

    3~4번을 보는데도 이해가 안간다.

    특히나 process.nextTick() 뒤로는 진짜 이해가 안간다.

    그럼에도, event loop라는 개념이 조금은 뚜렷해진다.

    • setImmediate는 콜백을 작업 큐의 앞 쪽에 밀어넣는 것이 아니라 고유한 작업 공간이 있는 점.
    • setImmediate와 setTimeout의 설계된 방식의 차이
    • 각 단계에서의 역할

    처음으로 DOC 문서 자체를 다 번역을 해보았다. 워낙, 영어 실력이 형편 없어서 번역기의 힘을 빌렸지만,

    어떻게든 해석을 할려고 노력했다.

    이렇게 하는 것이 무식할 수도 있지만, 내가 생각하기에 좋은 공부방법인 것 같다.

    일단은 영어보는 것에 익숙 해 지자.

     


    참조

    https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/

    https://evan-moon.github.io/2019/08/01/nodejs-event-loop-workflow/#%EB%8C%80%ED%91%9C%EC%A0%81%EC%9D%B8-%EC%9E%98%EB%AA%BB%EB%90%9C-%EA%B0%9C%EB%85%90%EB%93%A4

    '프로그래밍 > Node' 카테고리의 다른 글

    Blocking vs Non-Blocking  (0) 2021.07.20
    Node.js - 비동기 중심 모델  (0) 2021.06.26
    NodeJs란  (0) 2021.05.14
    Node-passport  (0) 2021.03.10
Designed by Tistory.