一步一步实现一个基于信号量与队列的简单并发控制类

思路

  1. 先不进行封装,如果不控制并发数,正常写,应该在请求处这样实现:

    const profiles = await Promise.allSettled(uids.map((id) => requestProfile(id)))
    

    这样,请求全部并发,总耗时1000ms多一点

  2. 分析可知,await等待的是所有请求方法返回的Promise对象(假设为requestP)的完成,请求方法直接调用,导致全部并发。所以,首先从调用角度来看,此处await的应该是所有的另外的Promise对象(假设为someP)的完成,我们可以想到:

    • someP状态转变时机应该由我们设计的并发控制类内部进行控制;
    • someP结果应该为requestP的结果。

    只要做到了这两点,就可以实现并发的控制

  3. 根据2中的分析,我们可以将1中的代码改写:

    假设异步控制类的名称为AsyncLimitQueue,获取Promise对象someP的方法为inQueue

    // 创建异步队列
    const queue = new AsyncLimitQueue(5);
    // 将请求方法加入到异步控制类的实例中
    const profiles = await Promise.allSettled(uids.map((id) => queue.inQueue(() => requestProfile(id))));
    
  4. 现在我们的代码如下:

    // 模拟用户头像请求
    async function requestProfile(uid: string): Promise<string> {
      return new Promise((resolve) => {
        setTimeout(() => {
          resolve(`UserProfile-${uid}`);
        }, 1000);
      });
    }
    
    // 在这里封装请求类...
    class AsyncLimitQueue {
      constructor() {}
        
      inQueue() {}
    }
    
    async function loadUserProfiles() {
      const start = new Date().getTime();
      const uids: string[] = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"];
      // 创建异步队列
      const queue = new AsyncLimitQueue(5);
      // 将请求方法加入到异步控制类的实例中
      const profiles = await Promise.allSettled(uids.map((id) => queue.inQueue(() => requestProfile(id))));
      console.log(new Date().getTime() - start > 2000 && new Date().getTime() - start < 3000);
    }
    
  5. 由3可知,new调用的时候,需要传递最大并发数,也就是信号量。实现constructor:

    // 在这里封装请求类...
    class AsyncLimitQueue {
      #limit = 7;
        
      constructor(limit?: number) {
        this.#limit = limit ?? this.#limit;
      }
        
      inQueue() {}
    }
    
  6. 由2、3可知,inQueue需要返回一个Promise对象;并且还需要内部控制这个Promise对象的状态转变、保存请求方法(也就是3中的() => requestProfile(id))。

    此时我们需要新建一个队列,用来保存inQueue返回的Promise的操作方法(rejectresolve),以及请求方法(也就是3中的() => requestProfile(id)),以便之后的执行。对inQueue方法进行实现:

    // 在这里封装请求类...
    type QueueMember<T> = {
      toRequest: () => Promise<T>;
      resolve: (value: T | PromiseLike<T>) => void;
      reject: (reason?: any) => void;
    };
    
    class AsyncLimitQueue {
      #limit = 7;
      #waitingQueue: Array<QueueMember<any>> = [];
        
      constructor(limit?: number) {
        this.#limit = limit ?? this.#limit;
      }
        
      inQueue<T>(toRequest: () => Promise<T>): Promise<T> {
        return new Promise<T>((resolve, reject) => {
          // 所有请求先入队
          this.#waitingQueue.push({
            toRequest,
            resolve,
            reject,
          } as QueueMember<T>);
        });
      }
    }
    
  7. 此时inQueue方法还没有完全实现,因为我们每次入队,都应当检查现在是否可以请求,并尝试请求。

    对于尝试请求方法#execWaitingQueue来说:

    • 此时如果信号量不为0,且#waitingQueue队列中存在元素则可以请求。

    如果可以请求,那么:

    • 应该立即减少一个信号量,
    • #waitingQueue出队一个Promise操作对象QueueMember
    • 执行请求方法(QueueMember.toRequest方法);
    • 并将对2中所属requestP的状态变化、requestP的结果,同步到someP(使用QueueMember.resolveQueueMember.reject
    // 在这里封装请求类...
    type QueueMember<T> = {
      toRequest: () => Promise<T>;
      resolve: (value: T | PromiseLike<T>) => void;
      reject: (reason?: any) => void;
    };
    
    class AsyncLimitQueue {
      #limit = 7;
      #waitingQueue: Array<QueueMember<any>> = [];
    
      constructor(limit?: number) {
        this.#limit = limit ?? this.#limit;
      }
    
      // 尝试执行请求的方法
      #execWaitingQueue() {
        if (this.#limit === 0 || this.#waitingQueue.length === 0) return;
    
        const { toRequest, resolve, reject } = this.#waitingQueue.shift()!;
        this.#limit--; // 减少信号量
        // 同步Promise的状态与结果
        toRequest()
          .then(resolve)
          .catch(reject)
      }
    
      inQueue<T>(toRequest: () => Promise<T>): Promise<T> {
        return new Promise<T>((resolve, reject) => {
          // 所有请求先入队
          this.#waitingQueue.push({
            toRequest,
            resolve,
            reject,
          } as QueueMember<T>);
          // 尝试执行请求
          this.#execWaitingQueue();
        });
      }
    }
    
  8. 此时还有两个问题没有解决:

    • 我们的信号量还没有还原,在第7步中,我们只是减少了信号量;
    • 我们不只是入队的时候需要尝试请求,当请求结束的时候,增加信号量的同时,也要尝试请求

    可以直接在toRequest()的finally中同时处理这两个问题:

    #execWaitingQueue() {
      if (this.#limit === 0 || this.#waitingQueue.length === 0) return;
    
      const { toRequest, resolve, reject } = this.#waitingQueue.shift()!;
      this.#limit--; // 减少信号量
      toRequest()
        .then(resolve)
        .catch(reject)
        .finally(() => {
          this.#limit++; // 增加信号量
          this.#execWaitingQueue(); // 尝试执行等待队列中的请求
        });
    }
    

完整代码:

async function requestProfile(uid: string): Promise<string> {
  // 模拟用户头像请求
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(`UserProfile-${uid}`);
    }, 1000);
  });
}

// 在这里封装请求类...
type QueueMember<T> = {
  toRequest: () => Promise<T>;
  resolve: (value: T | PromiseLike<T>) => void;
  reject: (reason?: any) => void;
};

class AsyncLimitQueue {
  #limit = 7;
  #waitingQueue: Array<QueueMember<any>> = [];

  constructor(limit?: number) {
    this.#limit = limit ?? this.#limit;
  }

  #execWaitingQueue() {
    if (this.#limit === 0 || this.#waitingQueue.length === 0) return;

    const { toRequest, resolve, reject } = this.#waitingQueue.shift()!;
    this.#limit--; // 减少信号量
    toRequest()
      .then(resolve)
      .catch(reject)
      .finally(() => {
        this.#limit++; // 增加信号量
        this.#execWaitingQueue(); // 尝试执行等待队列中的请求
      });
  }

  inQueue<T>(toRequest: () => Promise<T>): Promise<T> {
    return new Promise<T>((resolve, reject) => {
      // 所有请求先入队
      this.#waitingQueue.push({
        toRequest,
        resolve,
        reject,
      } as QueueMember<T>);
      // 尝试执行等待队列
      this.#execWaitingQueue();
    });
  }
}

async function loadUserProfiles() {
  const start = new Date().getTime();
  const uids: string[] = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"];
  // 创建异步队列
  const queue = new AsyncLimitQueue(5);
  // 将请求方法加入到异步控制类的实例中
  const profiles = await Promise.allSettled(uids.map((id) => queue.inQueue(() => requestProfile(id))));
  console.log(new Date().getTime() - start > 2000 && new Date().getTime() - start < 3000, profiles);
}

loadUserProfiles();

泛型丢失问题

上面的完整代码可以正常运行,类型系统也未报错,不过有一点小瑕疵。问题在于入队this.#waitingQueue.push()这一步时,toRequestresolve的泛型是正确的:
image

但是出队this.#waitingQueue.shift()的时候,toRequestresolve会丢失泛型:
image

具体的原因以及解决方法可以在我的另一篇随笔一次泛型丢失问题记录 - Cat_Catcher - 博客园 (cnblogs.com)找到,如果不解决也不影响使用。由于解决此问题会引来额外的逻辑,所以没有在本文中解决。

posted @ 2024-03-11 12:44  Cat_Catcher  阅读(7)  评论(0编辑  收藏  举报
#