这段前端代码存在并发读取竞态的问题吗?

5 天前
 supuwoerc

这两天调试别人项目中的一段 js 代码,作用是刷新 token ,但是验证下来发现有很小的几率会触发多次刷新 token 的动作(下面代码中的 FIXME 位置),特别是 Promise.all 去发送一批请求的时候,我 google 了一圈,没研究明白,因为复现起来很困难,所以请教大家,代码中读取 isRefreshing 是安全的吗?我让 cursor 和 copilot 解释都是说 js 不启用 worker 是不存在并发问题的,但是从结果来看,确实有不止一个请求进入了刷新 token 的分支,我把这个情况描述完,cursor 让我引入 sync-mutex 加锁,和一开始的解释完全不一样,我在 StackOverflow 和 medium 中也找到几篇类似的文章,都是借助了防抖/记忆函数来解决,实在弄不清楚这块读写 isRefreshing 到底是不是安全的。

还看到了一篇锁的文章,感觉很类似我遇到的这个问题: https://jackpordi.com/posts/locks-in-js-because-why-not

伪代码如下:

let isRefreshing = false // 标记是否正在刷新 token 
let requests: Array<(token: string, err?: string) => void> = [] // 需要重试的请求列表

client.interceptors.response.use((response: AxiosResponse) => {
            const { config, status } = response
            const { code } = response.data
            if (status >= 500) {
                return Promise.reject("服务器错误")
            } else if (code == 10003) {
                // access token 过期,尝试刷新 token
                const { refreshToken } = user.useLoginStore.getState()
                if (refreshToken) {
                    // FIXME: ?? 存在并发读取 isRefreshing 为 false 导致发出多次刷新 token 的请求
                    if (!isRefreshing) {
                        isRefreshing = true
                        return refreshToken()
                            .then(({ data }) => {
                                const { code } = data
                                if (code === 10000) {
                                    user.useLoginStore.setState((state) => {
                                        state.token = data.data.token
                                    })
                                    config.headers["Authorization"] = data.data.token
                                    const retry = client(config)
                                    requests.forEach((cb) => cb(data.data.token))
                                    requests = []
                                    return retry
                                } else {
                                    return Promise.reject(data.message)
                                }
                            })
                            .catch((err) => {
                                const msg = isError(err) ? err.message : err
                                requests.forEach((cb) => cb("", msg))
                                requests = []
                                publishInvalidTokenEvent(msg)
                            })
                            .finally(() => {
                                isRefreshing = false
                            })
                    } else {
                        return new Promise((resolve, reject) => {
                            requests.push((token: string, err?: string) => {
                                if (err) {
                                    reject(err)
                                } else {
                                    config.headers["Authorization"] = token
                                    resolve(client(config))
                                }
                            })
                        })
                    }
                } else {
                    requests.forEach((cb) => cb("", "登录过期")
                    requests = []
                    publishInvalidTokenEvent("登录过期")
                }
            } else if (code === 10000) {
                return response.data.data
            } else if (code == 10006) {
                // 长 token 失效
                requests.forEach((cb) => cb("", "登录过期")
                requests = []
                publishInvalidTokenEvent("登录过期")
            } else {
                return Promise.reject(response.data.message || response.data.msg)
            }
        })
2523 次点击
所在节点    JavaScript
39 条回复
Niphor
5 天前
根源是 有的请求已经进 finally 了,但是有的请求 response 刚进 interceptor
InDom
5 天前
isRefreshing 这个锁不应该是 bool 型 ,而应该是最少三个状态

false, refreshing, true

更好的方案是记录 refreshing 的时间, 再加一个超时的机制.

如果发现 true 就直接使用, 如果是 false 就设置为当前时间并开始更新 token,

如果是 refreshing 的时间, 就检查是否超时(指上一个任务太久未完成)

如果 refreshing 超时, 就自己重新设置 refreshing 时间并开始更新 token

如果 refreshing 未超时, 就设置个随机时间后重新检查 isRefreshing 状态.
supuwoerc
5 天前
@kamilic 醍醐灌顶!我觉得这个分析才解释了真正导致问题的原因,我之前已经怀疑人生了,没想到这种时序导致的问题。
duuu
5 天前
你的 isRefreshing 不要做成全局的,改成单个接口自己判断使用。
supuwoerc
5 天前
@LuckyRock
@InDom
@geelaw 感谢各位大佬,我依然意识到请求时序才是真正导致问题的原因,之前一直盯着判断看,钻牛角尖已经怀疑人生了😂
mrzhiin
5 天前
当接口的响应为令牌过期错误的时候,在刷新令牌之前,先判断下接口使用的令牌和当前状态里的令牌是否一致,如果不一致,替换为当前状态里令牌重新再发起请求
LOWINC
5 天前
let isRefreshing = false;

function request(success, time) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (success) {
resolve({});
} else {
reject();
}
}, time);
});
}

Promise.all([
request(false, 100)
.then(() => {})
.catch(() => {
if (!isRefreshing) {
isRefreshing = true;
console.log('refresh1:', isRefreshing);
setTimeout(() => {
isRefreshing = false;
}, 100);
}
}),
request(false, 3000)
.then(() => {})
.catch(() => {
if (!isRefreshing) {
isRefreshing = true;
console.log('refresh2:', isRefreshing);
setTimeout(() => {
isRefreshing = false;
}, 3000);
}
}),
]);




可能就是两次 10003
supuwoerc
5 天前
@mrzhiin 嗯嗯,感谢大佬,知道原因的我也想到了这样判断来解决~
canvascat
5 天前
不用加锁,复用一下 `refreshTokenPromise`应该就行了

```
declare const orginalRefreshToken: () => Promise<any>;

const refreshToken = () => {
refreshToken.current ??= orginalRefreshToken().finally(() => {
refreshToken.current = null;
});
return refreshToken.current;
};

refreshToken.current = null as Promise<any> | null;
```
supuwoerc
5 天前
@canvascat 你的这个实际上也是一种锁,还是会有楼上的时序问题~
NotLongNil
5 天前
这段代码一堆 if ,看得好难受
bli22ard
5 天前
js 单线程没有并发读写问题,js 的 io 都是异步的,只有开了异步后,异步唤醒后的顺序问题。在执行 if (!isRefreshing) {
isRefreshing = true; } 这个过程中,不会有其他线程来竞争, 但是如果你执行了 settimeout setinterval 或者 ajax 等异步,那就会去检查其他事件是否就绪,然后执行其他的地方。
leoskey
5 天前
在多个请求响应时间不同的情况下,可能会出现重复刷新 token 。

例如,A 请求响应时间 100ms ,B 请求响应时间 300ms 。
A 请求响应 token 过期,在 100ms 内完成: isRefreshing = true , 执行 refresh 流程,isRefreshing = false 。
此时才过去 200ms 。

再过 100ms 后,B 请求响应 token 过期,发现 isRefreshing = false ,再次执行 refresh 。

试试双重检查:
1. 如果是 JWT ,先检查本地 token 是否过期
2. 如果服务器响应 token 无效
3. 再检查一次本地 token 是否与上次相同,不相同说明刷新过了,相同就执行 refresh
shintendo
5 天前
用一个 outdatedToken 变量代替 isRefreshing

if (!isRefreshing){ isRefreshing = true }
换成
if(outdatedToken !== config.token) { outdatedToken = config.token }

去掉 isRefreshing = false 的操作

其它不变
GGbeng1
5 天前
还有一种方法,过期时将所有请求地址记录;同时 cancel 掉所有请求;刷新后从新发送或者直接刷新页面😂
LiuJiang
5 天前
没用 AI 去理解代码吗
dcatfly
5 天前
这个问题打断点调试应该很快就能发现是时序的问题。
不过这个问题还挺适合测 AI 的,拿给各家 AI 问了下,只有 gpt5 thinking 能一次给对答案,claude sonnet/opus4 需多轮对话、gemini 、qwen3/glm4.5/k2 都不能给出正确答案。
unclejoker
5 天前
丢给 gpt 一下不就得出答案了吗...
xibexp
4 天前
有没有返回 token 过期时间,有的话直接在外部定时判断是否快过期,然后续期

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://ex.noerr.eu.org/t/1156990

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX