Skip to content

mutex lock 문제

simjaesung edited this page Dec 1, 2024 · 1 revision

문제

현재 경찰 활동을 위해 executePolice 메서드 사용 시 락으로 인해 락 경쟁 상황 발생

games MutexMap {
50|BE  |   _map: Map(0) {},
50|BE  |   mutex: Mutex {
50|BE  |     _semaphore: Semaphore {
50|BE  |       _value: 1,
50|BE  |       _cancelError: Error: request for lock canceled
50|BE  |           at Object.<anonymous> (/root/web12-MafiaCamp/BE/node_modules/async-mutex/lib/errors.js:6:22)
50|BE  |           at Module._compile (node:internal/modules/cjs/loader:1469:14)
50|BE  |           at Module._extensions..js (node:internal/modules/cjs/loader:1548:10)
50|BE  |           at Module.load (node:internal/modules/cjs/loader:1288:32)
50|BE  |           at Module._load (node:internal/modules/cjs/loader:1104:12)
50|BE  |           at Module.require (node:internal/modules/cjs/loader:1311:19)
50|BE  |           at require (node:internal/modules/helpers:179:18)
50|BE  |           at Object.<anonymous> (/root/web12-MafiaCamp/BE/node_modules/async-mutex/lib/Semaphore.js:4:16)
50|BE  |           at Module._compile (node:internal/modules/cjs/loader:1469:14)
50|BE  |           at Module._extensions..js (node:internal/modules/cjs/loader:1548:10),
50|BE  |       _queue: [],
50|BE  |       _weightedWaiters: []
50|BE  |     }
50|BE  |   }
50|BE  | }
  • 한 작업이 계속 락을 잡고 있어서 다른 작업이 타임 아웃으로 실패하는 상황

해결 과정

문제 해결 과정을 기록합니다.

@Injectable()
export class MafiaCountdownTimer implements CountdownTimer {

  private readonly stopSignals = new MutexMap<GameRoom, Subject<any>>();
  private readonly pauses = new MutexMap<GameRoom, boolean>();

  async start(room: GameRoom, situation: string): Promise<void> {
    if (await this.stopSignals.has(room)) {
      throw new DuplicateTimerException();
    }

    await this.stopSignals.set(room, new Subject());
    await this.pauses.set(room, false);

    let timeLeft: number = TIMEOUT_SITUATION[situation];
    const currentSignal = await this.stopSignals.get(room);
    let paused = await this.pauses.get(room);
    return new Promise<void>((resolve) => {
      interval(1000).pipe(
        takeUntil(currentSignal),
        takeWhile(() => timeLeft > 0 && !paused),
      ).subscribe({
        next: async () => {
          paused = await this.pauses.get(room);
          room.sendAll('countdown', {
            situation: situation,
            timeLeft: timeLeft,
          });
          timeLeft--;
        },
        complete: async () => {
          room.sendAll('countdown-exit', {
            situation: situation,
            timeLeft: timeLeft,
          });
          await this.stop(room);
          resolve();
        },
      });
    });
  }

  private async pause(room: GameRoom): Promise<void> {
    await this.pauses.set(room, true);
  }

  private async cleanup(room: GameRoom): Promise<void> {
    const signal = await this.stopSignals.get(room);
    if (!signal) {
      // 기존에는 signal 유무와 상관없이 항상 Exception이 터지는 코드라서 수정하였습니다.
      throw new NotFoundTimerException();
    }
    signal.next(null);
    signal.complete();
    await this.stopSignals.delete(room);
    await this.pauses.delete(room);
  }

  async stop(room: GameRoom): Promise<void> {
    await this.pause(room);
    await this.cleanup(room);
  }

}
  • Timer에서 락을 걸며 진행하고 있음
    • 그런데 Timer는 한번 실행되면 계속 진행이 되므로 진정한 동시성 문제가 발생할 가능성이 낮음
    • 그래서 다른 작업이 타임아웃으로 인해 실패

이것을 해봄

@Injectable()
export class TotalGameManager implements VoteManager, PoliceManager {
  private readonly games = new MutexMap<GameRoom, Map<string, PlayerInfo>>();
  private readonly ballotBoxs = new MutexMap<GameRoom, Map<string, string[]>>();
  private readonly policeInvestigationMap = new MutexMap<GameRoom, boolean>();

  async register(gameRoom: GameRoom, players: Map<GameClient, MAFIA_ROLE>): Promise<void> {
    if (!await this.games.get(gameRoom)) {

      const gameInfo = new Map<string, PlayerInfo>();
      players.forEach((role, client) => {
        gameInfo.set(client.nickname, { role, status: USER_STATUS.ALIVE });
      });
      await this.games.set(gameRoom, gameInfo);
      console.log('gameRoom', await this.games.get(gameRoom));
    }
  }

  private async killUser(gameRoom: GameRoom, player: string): Promise<void> {
    const gameInfo = await this.games.get(gameRoom);
    if (!gameInfo) {
      throw new NotFoundGameRoomException();
    }

    const playerInfo = gameInfo.get(player);

    playerInfo.status = USER_STATUS.DEAD;
    gameInfo.set(player, playerInfo);

    gameRoom.sendAll('vote-kill-user', { player, job: playerInfo.role });
  }

  async registerBallotBox(gameRoom: GameRoom): Promise<void> {
    const ballotBox = await this.ballotBoxs.get(gameRoom);
    const candidates: string[] = ['INVALIDITY'];
    if (!ballotBox) {
      const gameInfo = await this.games.get(gameRoom);
      if (!gameInfo) {
        throw new NotFoundGameRoomException();
      }
      const newBallotBox = new Map<string, string[]>();
      gameInfo.forEach((playerInfo, client) => {
        if (playerInfo.status === USER_STATUS.ALIVE) {
          newBallotBox.set(client, []);
          candidates.push(client);
        }
      });
      // 무효표 추가
      newBallotBox.set('INVALIDITY', []);
      await this.ballotBoxs.set(gameRoom, newBallotBox);
    } else {
      ballotBox.forEach((votedUsers, client) => {
        candidates.push(client);
      });
    }

    gameRoom.sendAll('send-vote-candidates', candidates);
  }

  /*
    무효표인 경우 to에 INVALIDITY로 보내면 됩니다.
   */
  async cancelVote(gameRoom: GameRoom, from: string, to: string): Promise<void> {
    await this.checkVoteAuthority(gameRoom, from);
    const ballotBox = await this.ballotBoxs.get(gameRoom);
    console.log(from, to, ballotBox);
    const toVotes = ballotBox.get(to);
    const voteFlag = this.checkVote(ballotBox, from);
    if (voteFlag) {
      ballotBox.set(to, toVotes.filter(voteId => voteId !== from));
    }
    this.sendVoteCurrentState(ballotBox, gameRoom);
  }

  private sendVoteCurrentState(ballotBox: Map<string, string[]>, gameRoom: GameRoom) {
    const voteCountMap: Record<string, number> = {};
    ballotBox.forEach((votedUsers, client) => {
      voteCountMap[client] = votedUsers.length;
    });
    gameRoom.sendAll('vote-current-state', voteCountMap);
  }

  private async checkVoteAuthority(gameRoom: GameRoom, from: string): Promise<void> {
    const game = await this.games.get(gameRoom);
    if (!game) {
      throw new NotFoundBallotBoxException();
    }

    const fromClientInfo = game.get(from);
    if (fromClientInfo.status !== USER_STATUS.ALIVE) {
      throw new UnauthorizedUserBallotException();
    }
  }

  /*
      무효표인 경우 to에 INVALIDITY로 보내면 됩니다.
     */
  async vote(gameRoom: GameRoom, from: string, to: string): Promise<void> {
    await this.checkVoteAuthority(gameRoom, from);
    const ballotBox = await this.ballotBoxs.get(gameRoom);
    const toVotes = ballotBox.get(to);
    const voteFlag = this.checkVote(ballotBox, from);
    if (!voteFlag) {
      toVotes.push(from);
      ballotBox.set(to, toVotes);
    }
    this.sendVoteCurrentState(ballotBox, gameRoom);
  }

  private checkVote(ballotBox: Map<string, string[]>, fromInfo: string): boolean {
    let voteFlag = false;
    ballotBox.forEach((votedUser) => {
      if (votedUser.some(voteId => voteId === fromInfo)) {
        voteFlag = true;
      }
    });
    return voteFlag;
  }

  async primaryVoteResult(gameRoom: GameRoom): Promise<VOTE_STATE> {
    const ballotBox = await this.ballotBoxs.get(gameRoom);
    if (!ballotBox) {
      throw new NotFoundBallotBoxException();
    }
    await this.voteForYourself(ballotBox);

    const newBalletBox = new Map<string, string[]>();
    const maxVotedUsers = this.findMostVotedUser(ballotBox);

    if ((maxVotedUsers.length === 1 && maxVotedUsers[0] !== null) || (maxVotedUsers.length > 1)) {
      /*
      투표결과가 1등이 있는 경우 혹은 공동이 있는 경우
       */
      maxVotedUsers.forEach((votedUser) => {
        if (votedUser !== null) {
          newBalletBox.set(votedUser, []);
        }
      });
      newBalletBox.set('INVALIDITY', []);
      await this.ballotBoxs.set(gameRoom, newBalletBox);
      gameRoom.sendAll('primary-vote-result', maxVotedUsers);
      return VOTE_STATE.PRIMARY;
    }
    gameRoom.sendAll('primary-vote-result', maxVotedUsers);
    return VOTE_STATE.INVALIDITY;
  }

  private findMostVotedUser(ballotBox: Map<string, string[]>): string[] {
    let maxVotedUsers: string[] = [];
    let maxCnt = -1;
    ballotBox.forEach((votedUsers, client) => {
      if (maxCnt < votedUsers.length) {
        maxVotedUsers = [client];
        maxCnt = votedUsers.length;
      } else if (maxCnt === votedUsers.length) {
        maxVotedUsers.push(client);
      }
    });
    return maxVotedUsers;
  }

  private voteForYourself(ballotBox: Map<string, string[]>) {
    ballotBox.forEach((votedUsers, client) => {
      if (!this.checkVote(ballotBox, client) && client !== 'INVALIDITY') {
        votedUsers.push(client);
      }
    });
  }

  async finalVoteResult(gameRoom: GameRoom): Promise<VOTE_STATE> {
    const ballotBox = await this.ballotBoxs.get(gameRoom);
    if (!ballotBox) {
      throw new NotFoundBallotBoxException();
    }
    const mostVotedUser = this.findMostVotedUser(ballotBox);

    await this.ballotBoxs.delete(gameRoom);

    if (mostVotedUser.length === 1 && mostVotedUser[0] !== null) {
      await this.killUser(gameRoom, mostVotedUser[0]);
    } else {
      gameRoom.sendAll('vote-kill-user', null);
    }
    return VOTE_STATE.FINAL;
  }

  async executePolice(gameRoom: GameRoom, police: string, criminal: string): Promise<void> {
    const investigationFlag = await this.policeInvestigationMap.get(gameRoom);
    let policeFlag = false;
    let criminalFlag = false;
    let criminalJob: MAFIA_ROLE;

    const userInfos = await this.games.get(gameRoom);
    console.log('gameRoom2', gameRoom);
    console.log('games', this.games);
    console.log('userInfos', userInfos);
    userInfos.forEach((playerInfo, client) => {
      if (police === client && playerInfo.role === MAFIA_ROLE.POLICE && playerInfo.status === USER_STATUS.ALIVE) {
        policeFlag = true;
      } else if (criminal === client && playerInfo.status === USER_STATUS.ALIVE) {
        criminalFlag = true;
        criminalJob = playerInfo.role;
      }
    });
    if (!investigationFlag && policeFlag && criminalFlag) {
      await this.policeInvestigationMap.set(gameRoom, true);
      const policeClient = gameRoom.clients.find(
        (client) => client.nickname === police,
      );
      if (policeClient) {
        policeClient.send('police-investigation-result', { criminal, criminalJob });
      }
    }
  }

  async finishPolice(gameRoom: GameRoom): Promise<void> {
    await this.policeInvestigationMap.delete(gameRoom);
  }

  async initPolice(gameRoom: GameRoom): Promise<void> {
    await this.policeInvestigationMap.set(gameRoom, false);
  }
}
  • JS/TS에서는 비교할 때 참조 동일성을 사용하므로 객체를 key로 하는게 아닌 객체의 roomId를 key로 만들어 봄
    • 그러면 해당 string key를 통해 비교하므로 문제가 안 생긴다고 생각
@Injectable()
export class TotalGameManager implements VoteManager, PoliceManager {
  private readonly games = new MutexMap<string, Map<string, PlayerInfo>>();
  private readonly ballotBoxs = new MutexMap<string, Map<string, string[]>>();
  private readonly policeInvestigationMap = new MutexMap<string, boolean>();

  async register(gameRoom: GameRoom, players: Map<GameClient, MAFIA_ROLE>): Promise<void> {
    if (!await this.games.get(gameRoom.roomId)) {

      const gameInfo = new Map<string, PlayerInfo>();
      players.forEach((role, client) => {
        gameInfo.set(client.nickname, { role, status: USER_STATUS.ALIVE });
      });
      await this.games.set(gameRoom.roomId, gameInfo);
      console.log('gameRoom', await this.games.get(gameRoom.roomId));
    }
  }

  private async killUser(gameRoom: GameRoom, player: string): Promise<void> {
    const gameInfo = await this.games.get(gameRoom.roomId);
    if (!gameInfo) {
      throw new NotFoundGameRoomException();
    }

    const playerInfo = gameInfo.get(player);

    playerInfo.status = USER_STATUS.DEAD;
    gameInfo.set(player, playerInfo);

    gameRoom.sendAll('vote-kill-user', { player, job: playerInfo.role });
  }

  async registerBallotBox(gameRoom: GameRoom): Promise<void> {
    const ballotBox = await this.ballotBoxs.get(gameRoom.roomId);
    const candidates: string[] = ['INVALIDITY'];
    if (!ballotBox) {
      const gameInfo = await this.games.get(gameRoom.roomId);
      if (!gameInfo) {
        throw new NotFoundGameRoomException();
      }
      const newBallotBox = new Map<string, string[]>();
      gameInfo.forEach((playerInfo, client) => {
        if (playerInfo.status === USER_STATUS.ALIVE) {
          newBallotBox.set(client, []);
          candidates.push(client);
        }
      });
      // 무효표 추가
      newBallotBox.set('INVALIDITY', []);
      await this.ballotBoxs.set(gameRoom.roomId, newBallotBox);
    } else {
      ballotBox.forEach((votedUsers, client) => {
        candidates.push(client);
      });
    }

    gameRoom.sendAll('send-vote-candidates', candidates);
  }

  /*
    무효표인 경우 to에 INVALIDITY로 보내면 됩니다.
   */
  async cancelVote(gameRoom: GameRoom, from: string, to: string): Promise<void> {
    await this.checkVoteAuthority(gameRoom, from);
    const ballotBox = await this.ballotBoxs.get(gameRoom.roomId);
    const toVotes = ballotBox.get(to);
    const voteFlag = this.checkVote(ballotBox, from);
    if (voteFlag) {
      ballotBox.set(to, toVotes.filter(voteId => voteId !== from));
    }
    this.sendVoteCurrentState(ballotBox, gameRoom);
  }

  private sendVoteCurrentState(ballotBox: Map<string, string[]>, gameRoom: GameRoom) {
    const voteCountMap: Record<string, number> = {};
    ballotBox.forEach((votedUsers, client) => {
      voteCountMap[client] = votedUsers.length;
    });
    gameRoom.sendAll('vote-current-state', voteCountMap);
  }

  private async checkVoteAuthority(gameRoom: GameRoom, from: string): Promise<void> {
    const game = await this.games.get(gameRoom.roomId);
    if (!game) {
      throw new NotFoundBallotBoxException();
    }

    const fromClientInfo = game.get(from);
    if (fromClientInfo.status !== USER_STATUS.ALIVE) {
      throw new UnauthorizedUserBallotException();
    }
  }

  /*
      무효표인 경우 to에 INVALIDITY로 보내면 됩니다.
     */
  async vote(gameRoom: GameRoom, from: string, to: string): Promise<void> {
    await this.checkVoteAuthority(gameRoom, from);
    const ballotBox = await this.ballotBoxs.get(gameRoom.roomId);
    const toVotes = ballotBox.get(to);
    const voteFlag = this.checkVote(ballotBox, from);
    if (!voteFlag) {
      toVotes.push(from);
      ballotBox.set(to, toVotes);
    }
    this.sendVoteCurrentState(ballotBox, gameRoom);
  }

  private checkVote(ballotBox: Map<string, string[]>, fromInfo: string): boolean {
    let voteFlag = false;
    ballotBox.forEach((votedUser) => {
      if (votedUser.some(voteId => voteId === fromInfo)) {
        voteFlag = true;
      }
    });
    return voteFlag;
  }

  async primaryVoteResult(gameRoom: GameRoom): Promise<VOTE_STATE> {
    const ballotBox = await this.ballotBoxs.get(gameRoom.roomId);
    if (!ballotBox) {
      throw new NotFoundBallotBoxException();
    }
    await this.voteForYourself(ballotBox);

    const newBalletBox = new Map<string, string[]>();
    const maxVotedUsers = this.findMostVotedUser(ballotBox);

    if ((maxVotedUsers.length === 1 && maxVotedUsers[0] !== null) || (maxVotedUsers.length > 1)) {
      /*
      투표결과가 1등이 있는 경우 혹은 공동이 있는 경우
       */
      maxVotedUsers.forEach((votedUser) => {
        if (votedUser !== null) {
          newBalletBox.set(votedUser, []);
        }
      });
      newBalletBox.set('INVALIDITY', []);
      await this.ballotBoxs.set(gameRoom.roomId, newBalletBox);
      gameRoom.sendAll('primary-vote-result', maxVotedUsers);
      return VOTE_STATE.PRIMARY;
    }
    gameRoom.sendAll('primary-vote-result', maxVotedUsers);
    return VOTE_STATE.INVALIDITY;
  }

  private findMostVotedUser(ballotBox: Map<string, string[]>): string[] {
    let maxVotedUsers: string[] = [];
    let maxCnt = -1;
    ballotBox.forEach((votedUsers, client) => {
      if (maxCnt < votedUsers.length) {
        maxVotedUsers = [client];
        maxCnt = votedUsers.length;
      } else if (maxCnt === votedUsers.length) {
        maxVotedUsers.push(client);
      }
    });
    return maxVotedUsers;
  }

  private voteForYourself(ballotBox: Map<string, string[]>) {
    ballotBox.forEach((votedUsers, client) => {
      if (!this.checkVote(ballotBox, client) && client !== 'INVALIDITY') {
        votedUsers.push(client);
      }
    });
  }

  async finalVoteResult(gameRoom: GameRoom): Promise<VOTE_STATE> {
    const ballotBox = await this.ballotBoxs.get(gameRoom.roomId);
    if (!ballotBox) {
      throw new NotFoundBallotBoxException();
    }
    const mostVotedUser = this.findMostVotedUser(ballotBox);

    await this.ballotBoxs.delete(gameRoom.roomId);

    if (mostVotedUser.length === 1 && mostVotedUser[0] !== null) {
      await this.killUser(gameRoom, mostVotedUser[0]);
    } else {
      gameRoom.sendAll('vote-kill-user', null);
    }
    return VOTE_STATE.FINAL;
  }

  async executePolice(gameRoom: GameRoom, police: string, criminal: string): Promise<void> {
    console.log('All game rooms:', await this.games.keys());
    console.log('investgationFlag', await this.policeInvestigationMap.keys());
    const investigationFlag = await this.policeInvestigationMap.get(gameRoom.roomId);
    let policeFlag = false;
    let criminalFlag = false;
    let criminalJob: MAFIA_ROLE;

    const userInfos = await this.games.get(gameRoom.roomId);
    console.log('gameRoom2', gameRoom);
    console.log('games', this.games);
    console.log('userInfos', userInfos);
    userInfos.forEach((playerInfo, client) => {
      if (police === client && playerInfo.role === MAFIA_ROLE.POLICE && playerInfo.status === USER_STATUS.ALIVE) {
        policeFlag = true;
      } else if (criminal === client && playerInfo.status === USER_STATUS.ALIVE) {
        criminalFlag = true;
        criminalJob = playerInfo.role;
      }
    });
    if (!investigationFlag && policeFlag && criminalFlag) {
      await this.policeInvestigationMap.set(gameRoom.roomId, true);
      const policeClient = gameRoom.clients.find(
        (client) => client.nickname === police,
      );
      if (policeClient) {
        policeClient.send('police-investigation-result', { criminal, criminalJob });
      }
    }
  }

  async finishPolice(gameRoom: GameRoom): Promise<void> {
    await this.policeInvestigationMap.delete(gameRoom.roomId);
  }

  async initPolice(gameRoom: GameRoom): Promise<void> {
    await this.policeInvestigationMap.set(gameRoom.roomId, false);
  }
}
  • 그래서 key로 변경
  • 그래도 안됨

저것도 해봄

    {
      provide: VOTE_MANAGER,
      useClass: TotalGameManager,
    },
    {
      provide: POLICE_MANAGER,
      useClass: TotalGameManager,
    }
  • 두개가 다른 인스턴스를 가져서 호환이 안되는 문제가 발생일 수도 있음
 TotalGameManager,
    {
      provide: VOTE_MANAGER,
      useExisting: TotalGameManager,
    }
    {
      provide: POLICE_MANAGER,
      useExisting: TotalGameManager,
    },
  • 해결 완료

해결

해결책을 기록합니다.

    {
      provide: VOTE_MANAGER,
      useClass: TotalGameManager,
    },
    {
      provide: POLICE_MANAGER,
      useClass: TotalGameManager,
    }
  • 두개가 다른 인스턴스를 가져서 호환이 안되는 문제가 발생일 수도 있음
 TotalGameManager,
    {
      provide: VOTE_MANAGER,
      useExisting: TotalGameManager,
    }
    {
      provide: POLICE_MANAGER,
      useExisting: TotalGameManager,
    },
  • 해결 완료

MafiaCamp

📔소개
🎯프로젝트 규칙
💻프로젝트 기획
🍀기술 스택
📚그룹 회고
🌈개발 일지
🍀문제 해결 경험
🔧트러블 슈팅
Clone this wiki locally