node里边需不需要有lock类似的结构?
发布于 5 年前 作者 albert-ch-q 7232 次浏览 来自 问答

我对node的异步编程有一点点了解,不知道问的问题是否有价值,如果没什么价值,多包涵。 我知道node是单线程event loop的,没有抢占式“线程”,也就不存在一些memory race condition。 但如果出现逻辑上的race condition该怎么做?

当然即使需要lock,也不是多线程、多进程里面的锁,好像不需要os提供的原子操作?

我能想到下边这个例子。 例子比较简陋,只是想说明如果一次request handle需要 read和write各一次io,而且不希望read和write两次操作之间有数据被修改。 我在stackoverflow上搜类似的问题,有人提问,但是回答者寥寥。有回答提到AsyncLock这个库,我看到github上这个库,star很少,我试了一下这个库应该能满足一些要求。

下边这段代码print出来结果都是1(也就是hit没有被正确地自增)。

// Fake_Database is a fake database (or some other I/O)
class Fake_Database {
  constructor() {
    this._hit = 0;
  }

  // 异步做获取hit变量,类比从数据库中读
  async get_hit() {
    return this._hit;
  }

  // async set hit
  // 异步写hit,类比向数据库中写
  async set_hit(hit) {
    this._hit = hit;
  }
}

const service_fake_database = new Fake_Database();

// fake http handler
// 期望:每次有人访问handler1,hit++
async function handler1(req, res) {
  const hit_old = await service_fake_database.get_hit();
  const hit_new = hit_old + 1;
  await service_fake_database.set_hit(hit_new);
  res.success = true;
  res.hit = hit_new;
}

// client A 和 client B 并发地 call `handler1(req,res)`,就有可能出现交错
// [A get hit] -> [B get hit] -> [A set hit] -> [B set hit]
// 两次handler1 call,但hit只会+1一次。
// 在内存层面上没有竞争,但是逻辑上有竞争

// 我知道数据库应该会有 原子自增
// 我只是想举个例子,假如出现某种情况一定要将读和写(2次io)分离呢?
// 但又不希望有其他“线程”(就是指 一个执行顺序,不是传统意义上的线程)在读和写之间 修改数据
// 比如我写的代码里的先读后写

let count = 0;

async function send_req() {
  const req = {}, res = {};
  await handler1(req, res);
  console.log(`req${count++} -- ${res.hit}`);
}

async function main() {
  for(let i=0;i<1000;i++){
    send_req();
  }
}

main();

25 回复

这个就要看数据库的竞争实现了, 如果是多线程,node的共享内存是提供原子操作的 Atomics api

特地注册了账号来回答这个问题,题主所描述的并不是多线程,只是异步逻辑的执行顺序,题中的代码是直接用了es7新特性async/await写的,不知道题主是否了解其原理,async/await其实只是promise的语法糖,在函数中其实是一个接一个then下去的,而promise的嵌套执行顺序就涉及到了js的事件循环机制(宏事件,微事件,面试常考)(注意node和浏览器环境有细微差别)。上面这些代码始终输出1是必然的,因为所有的get必然先于set,所以这里根本不需要lock(终于回到正题了),而由于线程竞争导致的结果应该是不确定的,这也反证了并不需要题主所说的lock机制. 留个邮箱求职,杭州滨江前端/node岗 2.5年 291106637@qq.com

async function main() { for(let i=0;i<1000;i++){ await send_req(); } } 少了个await!循环中没有await,导致所有循环一次性进入异步队列;await是顺序执行,遇到await就丢队列(其向下的代码一并丢),所以await service_fake_database.get_hit() 一次全部进队列,获取的全是0,之后的同理。 题外话:您这么做的想法是不是:类似后端实现session认证;如果是这样子的话,可以参照一下express的session中间件的做法

只提一句:分布式锁 是唯一解法,因为你不可能单机单进程吧? 然后这个就跟语言没啥关系了。

@LeavesSky 我特意没有加await,因为main函数是在模拟有很多client(1000个)几乎同时向server发请求,就是说main 是client的行为,handler1应该可以处理这种情况才行(没有await的情况)

@atian25 如果我只是想在自己的进程里,在代码里限制一下执行顺序,如果涉及到分布式锁,还需要进程之间通信,甚至多个机器通信,overhead有点大,而且感觉稍微有点杀鸡用牛刀。还有,能在语言层面实现互斥肯定是最好的,不用引入过多的依赖。

@Dodd2013 我知道这段代码去掉async await语法糖之后,用promise的写法,也知道这段代码输出结果必然都是1。(我问题描述里有点没说清楚,可能会让人理解成我在问这段代码为什么能达到串行自增的目的) 我希望找到一种handler1的写法,能正确处理同时很多client的hit++。

这段代码里所有的set在eventloop都排在任何一个get后边(就是说 执行顺序是 client1 get, client2 get, client3 get, …, client1 set, client2 set, client3 set …) 但我想做的事情就是重拍 get和set的顺序,变成 client1 get set, client2 get set, client3 get set这样的串行。

单机单线程的话,我有个思路就是自己维护一个事件队列,将一次req的get,set打包,要不然就是锁了,因为第二次的get必须在第一次的set后,而第二次get时如果需要知道第一次req完成了没有,这就必须做一些额外的事情了

@Dodd2013 将get和set打包,这就有点广义的“锁”的意思了,理论上get和set是两次io操作。比如下边这个做法就是用AsyncLock把一次req的get和set打包了。

const AsyncLock = require('async-lock');
const lock = new AsyncLock();


// Fake_Database is a fake database (or some other I/O)
class Fake_Database {
  constructor() {
    this._hit = 0;
  }

  // async get hit count of website
  async get_hit() {
    return this._hit;
  }

  // async set hit
  async set_hit(hit) {
    this._hit = hit;
  }
}

const service_fake_database = new Fake_Database();

// fake http handler
async function handler1(req, res) {
  await lock.acquire('hit', async function (done) {
    const hit_old = await service_fake_database.get_hit();
    const hit_new = hit_old + 1;
    await service_fake_database.set_hit(hit_new);
    res.success = true;
    res.hit = hit_new;
    done();
  })
}

// client A and client B call `handler1` concurrently, and overlaps each other
// A[get hit] -> B[get hit] -> A[set hit] -> B[set hit]
// only one hit is counted while 2 clients visited handler
// There is no race condition in memory, but race in logic.

// I know that we can increment hit atomically inside database.
// What if we face some case that we must separate it to 2 I/Os?
// First read and then write, or some more complicated actions.


let count = 0;

async function send_req() {
  const req = {}, res = {};
  await handler1(req, res);
  // const hit = await service_fake_database.get_hit();
  console.log(`req${count++} -- ${res.hit}`);
}

async function main() {
  console.time("hit_time");
  await Promise.all(Array.from(new Array(1000)).map(x => send_req()))
  console.timeEnd("hit_time");
}

main();

@Dodd2013 “要不然就是锁了” 这句话是指node里面最好不出现锁吗?但感觉这种情况避免不了广义的“锁”的存在。广义的“锁”是指在代码块里有互斥、保护critial area的作用,但不一定是多线程模型里面由kernel提供的开销比较大的锁(比如锁内存,和上古单核时代关闭时钟中断之类的)。

我这个做法和楼主的 asynclock 一个道理… 这种情况避免不了 “锁” 吧

function getHandler(){
  let waitLock = Promise.resolve();
  return async function handler1(req, res) {
    let lastLock = waitLock;
    let next;
    waitLock = new Promise(r=>(next = r));
    await lastLock;
    try{
      const hit_old = await service_fake_database.get_hit();
      const hit_new = hit_old + 1;
      await service_fake_database.set_hit(hit_new);
      res.success = true;
      res.hit = hit_new;
    }catch(e){
      console.error(e);
    }finally{
      next();
    }
  }
}
const handler1 = getHandler()

上面的代码就能实现锁了. (然而我觉得我上面的代码有内存泄露问题…)

@abiuDoIT 试了,你的代码确实能原子自增,我还没完全看懂2333

@abiuDoIT 我对于异步编程仅仅算个票友,比较好奇,nodejs写server的时候,会遇到我说的这种需要“锁”的情况吗?遇到了会通过什么办法绕过吗?

既然这个问题存在需要保护的critial area,所以广义的锁是避免不了的,锁=阻止后来者先于前者操作完之前进行操作, 后者如果想要成立,那么这就是所谓的锁。

@Dodd2013 但为什么相关的轮子这么少,AsyncLock也只有两位数的star。

这种get又set的操作,实际中应该交给数据库做吧(没有经验,瞎猜的),逃了逃了

这个题很有趣,我尝试将send req用settimeout包起来,结果竟然出现了不同的答案,可以深究一下

感觉实际情况中的http请求,可能还是和这个会有差异,可以试着用jmeter然后搞一个真实的server试试

@albert-ch-q 一般不需要,数据库之类的io都有自带锁, 程序里的变量因为是单线程同步操作 也不需要.

我用服务器试过了,情况是一样的,问题确实存在,仔细想了一下,为什么这个库用的人少的原因可能是解决这个问题还不至于用到库吧,几行代码就能实现一个简单的等待机制。

批注 2019-09-21 092820.jpg 这个周下载量,很棒了。

有点意思的问题,关注了

@LeavesSky 不太了解npm的download数量多少算多,只是看到github上star的数量是挺少的。

借助redis也可以解决部分场景下的问题。

不管是不是自己实现…我觉得思路就 锁 或者 队列

回到顶部