竞态问题的复现和解决

696 阅读8分钟

竞争危害(race hazard)又名竞态问题、竞争条件(race condition),它旨在描述一个系统或者进程的输出依赖于不受控制的事件出现顺序或者出现时机。此词源自于两个信号试着彼此竞争,来影响谁先输出。

举例来说,如果计算机中的两个进程同时试图修改一个共享内存的内容,在没有并发控制的情况下,最后的结果依赖于两个进程的执行顺序与时机。而且如果发生了并发访问冲突,则最后的结果是不正确的。

复现场景

线程并发

C程序中多个线程同时读写同一个全局变量,由于一条自增语句会被编译器编译成多个汇编指令,在不同的执行顺序下会产生不同的结果。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
  
// Let us create a global variable to change it in threads
int g = 0;
  
// The function to be executed by all threads
void *func(void *vargp)
{
    // Change global variable
    ++g;
    
    // https://godbolt.org/z/7hx8crvnc
    // movl    g(%rip), %eax
    // addl    $1, %eax
    // movl    %eax, g(%rip)
    
    // Print global variable
    printf( Global variable: %d\n , g);
}
  
int main()
{
    int i;
    pthread_t tid;
  
    // Let us create three threads
    for (i = 0; i < 1000; i++)
        pthread_create(&tid, NULL, func, NULL);
    
    pthread_exit(NULL);

    return 0;
}

/**
 * Output:
 *
 * Global variable: 1
 * ...
 * ...
 * Global variable: 990
 * Global variable: 991
 * Global variable: 992
 *
 */

下面是值为0的全局变量自增两次后值等于1的时序表:

Thread 1Thread 2State
movl g(%rip), %eaxeax: 0, g: 0
addl $1, %eaxeax: 1, g: 0
movl g(%rip), %eaxeax: 0, g: 0
addl $1, %eaxeax: 1, g: 0
movl %eax, g(%rip)eax: 1, g: 1
movl %eax, g(%rip)eax: 1, g: 1

事件并发

项目中某个页面搜索表格在连续搜索两次后,第一次的响应比第二次后返回,表格的展示的内容是第一次的结果。

请求返回比较快的情况下我们直接请求并展示数据就可以了,但是当请求的响应时间不稳定时就会出现查询条件页面展示结果不一致的情况。

我们举例说明:

  • 首先用户在内容体裁选择框选中“横版短视频”,然后点击查询 , 这次我们称为第一次请求
  • 紧接着用户在内容体裁选择框选中“图文”,然后点击查询, 这次我们称为第二次请求

网络波动时,如果第二次请求的结果第一次请求先返回,页面上选择框展示的是 “图文” ,但是页面展示的列表数据却是第一次请求查询出的“横版短视频”对应的结果。

同样的在标签页切换、自动完成搜索显示下拉列表 等场景同样容易出现相似的异常情况。

如何解决

给临界区加锁

针对上文中提到的「线程并发」场景,容易想到的一种方式是给 ++g 操作加锁,这样就可以保证++g 相关的指令最多只有一个线程在执行。其他的线程要么已经执行完毕,要么在等待获取锁。

int g = 0;
pthread_mutex_t lock;

void *func(void *vargp)
{
    pthread_mutex_lock(&lock);
    ++g;
    pthread_mutex_unlock(&lock);

    printf( Global variable: %d\n , g);
}
  
int main()
{
    pthread_mutex_init(&lock, NULL)
    // ...
    pthread_mutex_destroy(&lock);
    return 0;
}

/**
 * Output:
 *
 * Global variable: 1
 * ...
 * ...
 * Global variable: 998
 * Global variable: 999
 * Global variable: 1000
 *
 */

在「事件并发」场景中也可以采取相似的思路,在数据加载过程中把查询按钮加锁(置为禁用状态),使得同时查询的请求最多只有一个。这样也就不会出现查询条件和页面展示结果不一致的情况了。

但是加锁有很多缺点,例如加锁会造成额外的开销,使用不慎也会造成死锁。同样的问题表现在 UI 界面就是会阻塞用户的操作,让界面的操作效率变得更低。

那么有没有一种不加锁来处理竞态问题的方法呢?使用 Lock Free 的方法可以帮我们减少使用锁,并且可以尽量减少多余的开销。

Compare and Swap (CAS)

Compare and Swap (CAS) 是一种 Lock Free 处理临界区问题的方法 ,由于它一般在编译后是原子指令,所以开销很小。

int g = 0;

void *func(void *vargp)
{
    int current, next;
    do {
      current = g;
      next = g + 1;
    } while(!__sync_bool_compare_and_swap(&g, current, next));

    printf( Global variable: %d\n , g);
}

// ...

CAS就是「比较然后交换」的意思,我们首先记录 g 的值并存入 current,然后计算 g 变更后的值 g + 1 存入 next,最后调用 CAS(&g, current, next)CAS 中会判断 g 是否与 current 相等,如果相等的话就会将 next 赋值给 g

这样就可以保证某个线程在变更g的过程中一定没有其他的线程变更了g的值。

同样的思路,在解决查询表格的竞态问题时可以这样来简单实现:

let lastType = '';
function guardFetchList(type) {
    lastType = type;
    fetchList(type).then(
        (list) => {
            if (lastType === type) {
                setList(list)
            }
        }
    )
}

在查询表格的场景中,由于我们不关心每次查询的结果,只关心最后一次。如果某次请求完成时,发现最后一次的请求参数,已经不是当前的请求参数时,就会把请求结果丢弃掉。从而保证了用户在界面上看到的结果一定是最后一次请求的结果。

用参数来标识不同的请求会存在什么问题吗?

guardFetchList('A'); // 第一次
guardFetchList('B'); // 第二次
guardFetchList('A'); // 第三次

// 考虑第一次请求的结果比第三次请求结果后返回的情况

抽象封装

上面的分析为我们处理竞态问题提供了一些思路,但是每次遇到相似的场景都需要写这么一段代码,就会比较麻烦,且容易出错。后续会分别通过高阶函数、React Hooks、RxJS来了解有哪些处理竞态问题的策略,以及如何对这些策略进行封装。

高阶函数

高阶函数是至少满足下列一个条件的函数:

  • 接受一个或多个函数作为输入
  • 输出一个函数

防抖,节流就是常见的高阶函数,下面看看用高阶函数该如何处理竞态问题。

fuction raceConditionGuard(fn) {
    let lastCallId = 0;
    return (...args) => {
        const currentCallId = ++lastCallId;
        lastCallId = currentCallId;
        return fn(...args).then(
            (value) => {
                if (currentCallId !== lastCallId) {
                    return new Promise(() => {});
                }
                return value;
            },
            (reason) => {
                if (currentCallId !== lastCallId) {
                    return new Promise(() => {});
                }
                return Promise.reject(reason);
            }
        )
    }
}

在 React 的类组件中可以这样使用:

class SomeComponent extends React.Component {
  guardedFetchSomething = raceConditionGuard(fetchSomething);

  loadItems = (...args) => {
    // Previous call will be automatically canceled (it will never resolve actually)
    this.guardedFetchSomething(...args)
      // Handle result somehow
      .then(this.handleResult);
  };

  // ...
}

React Hooks

下面这段代码是从 react-use useAsync 删除不相关逻辑,简化整合得到的代码。业务编码中经常遇到的 ahooks useRequest 在处理竞态问题时采用的方式也是一样的。

export default function useAsyncFn(
  fn, deps, initialState = { loading: false }
) {
  const lastCallId = useRef(0);
  const [state, set] = useState(initialState);

  useEffect(() => {
    const callId = ++lastCallId.current;

    if (!state.loading) {
      set((prevState) => ({ ...prevState, loading: true }));
    }

    fn().then(
      (value) => {
        callId === lastCallId.current && set({ value, loading: false });
        return value;
      },
      (error) => {
        callId === lastCallId.current && set({ error, loading: false });
        return error;
      }
    );
  }, deps);

  return state;
}

用自增的 ID 来标识不同的异步任务会存在什么问题吗?

Number.MAX_SAFE_INTEGER
// 9007199254740991
Number.MAX_SAFE_INTEGER + 1
// 9007199254740992
Number.MAX_SAFE_INTEGER + 1 + 1
// 9007199254740992
Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 1 + 1
// true

RxJS

Think of RxJS as Lodash for events.

RxJS 中同样的也提供了一系列的操作符(Operator)用来处理不同场景下的竞态问题。下面会逐个来说明不同操作符(Operator)的处理规则,以及如何用高阶函数来描述对应的规则。

mergeMap

mergeMap 跟我们平常直接调用返回 Promise 的函数并监听回调的行为是一致的,函数的调用顺序,以及最终结果的回调顺序是不会相互影响的。

function promiseMergeMap(fn) {
    return fn;
}

switchMap

switchMap 一般会用在搜索表格查询、标签页切换、自动完成搜索显示下拉列表等场景。在这些场景下我们只关心最后一次发起的请求结果,先发起的请求结果不应该影响后发起的请求。

fuction promiseSwitchMap(fn) {
    let lastCall;
    return (...args) => {
        const currentCall = fn(...args);
        lastCall = currentCall;
        return currentCall.then(
            (value) => {
                if (currentCall !== lastCall) {
                    return new Promise(() => {});
                }
                return value;
            },
            (reason) => {
                if (currentCall !== lastCall) {
                    return new Promise(() => {});
                }
                return Promise.reject(reason);
            }
        )
    }
}

exhaustMap

exhaustMap 一般用在表单提交的场景,在表单的提交过程中,重复点击提交按钮不会发起请求。

function promiseExhausMap(fn) {
    let loading;
    return (...args) => {
        if (loading) {
            return new Promise(() => {});
        }
        loading = true;
        return fn(...args).then(
            (value) => {
                loading = false;
                return value;
            },
            (reason) => {
                loading = false;
                return Promise.reject(reason);
            }
        )
    }
}

concatMap

考虑一个前端维护的购物车场景,每次加入商品到购物车时需要调用接口获取商品的最新信息,获取到信息后再把商品添加到购物车的最后面。为了保证点击的顺序和添加的顺序一致,可以采取这种策略。

function promiseConcatMap(fn) {
    let lastCall;
    return (...args) => {
        const currentCall = fn(...args);
        lastCall = Promise.resolve(lastCall).then(
            () => currentCall,
            () => currentCall
        );
        return lastCall;
    }
}

个人感受

最后,我来分享一下我个人的一些感受:

  • 有效知识迁移:前端领域的很多问题在底层已经出现并且解决过。如果对计算机领域的基础知识有相应了解的话,在遇到新领域的老问题时就能通过知识迁移的方式来处理这样的问题。
  • 考虑边界情况: 上文中提到的大多数异常案例在常规的操作中不易出现。这也意味着一旦出现问题就更加难以复现修复,在编码过程中逐步的识别和处理边界情况是很有必要的。
  • 不要过度设计:RxJS 从事件流的角度做了很好的抽象,并提供了一系列有用的工具来解决竞态问题。那么是不是遇到了竞态问题就直接引入 RxJS 呢?我认为不太妥当,因为增加了抽象就增加了代码的理解成本,通过一层函数封装来解决问题比引入 Observable、Operator 等概念来解决问题要好一些。

参考链接