端午游锦绣中华
Tag 端午, 锦绣中华, on by view 36

含苞待放的荷花

84ixt04a

夜景灯展

ap8qyfq8

oagum0wj


2023年上半年简单回顾
Tag 生活, 回顾, on by view 40

转眼间23年已经过了一半,简单回顾一下这半年的一些事情。

22年底12月份阳了,到了23年大家再也不关注新冠了

自从去年年底放开新冠防控之后,新冠在去年十二月份席卷全国,深圳万人空巷,就如同到了春节大家都回老家了一样,街头几乎见不到车辆与行人,阳了的人在家里躺着,没阳的人害怕阳了,不敢出门,但最终还是逃不过阳了。工作日公司办公室,整层楼到岗人数不足十人。

阳了很难受,发烧两天才停止发烧,之后感觉自己气管就像被刮掉一层皮一样,气管里面咳出来的全是浓痰,咳嗽异物感严重。恢复之后,还连续干咳了一个多月,才逐渐消停。发烧的这两天,还去社康“求助”过,平日人很少的社康,塞满了人,社康门口全是病人排队付钱做抗原检测,因为全国全网都买不到抗原检测试剂盒,公司发的五份抗原检测试剂盒也很快被家人用完了,我发烧第一天在社康测抗原没测出来,医生开了点治疗感冒的中成药,问到退烧药,医生说早就已经没有了;第二天再去社康测抗原,强阳性,我就直接回家了,反正医生那边连退烧药都开不出来,去看医生也没用,好在家里还有一盒退烧药。那几天也有邻居到处借退烧药,分了2颗给别人。

去年十二月份初次阳了之后,很长时间没有阳了,直到5月。大家都在传第二波新冠来了,然后5月底,我老婆二阳了,6月初,我也二阳了。第二轮感染新冠,只发烧了一晚上,吃了一颗退烧药,没多久就退烧了,第二天测出来强阳性,然后就是有点咽喉发炎,扁桃体的老毛病又犯了。乘着周末跑了一趟医院,医生开了点感冒药,然后咽喉含片,吃了两天,完全恢复了。总体来看,第二轮阳了病情比较轻,恢复也很快,遗留症状几乎没有。

困扰我多日的“牙齿”崩坏问题通过洗牙解决了

从去年开始,我的牙齿经常会因为咬到硬骨头就会崩下来一块,崩下来的东西外层黄色,内层黑色,还有臭味。我一度以为那是我牙齿的一部分骨质。再加上牙龈会出血,我以为我牙齿要坏掉了。于是挂了牙齿专科医院看了一下牙齿。医生看了之后,告诉我,我牙齿上全是结石,让我预约洗牙。最终,进洗牙室人生第一次洗了牙,那感觉真是“酸爽”,洗完之后,满嘴的血,牙龈出血严重。但是我照镜子之后才知道,原来我的牙齿可以这么白亮。遵医嘱,当天注意饮食,吃清淡的白粥,不受冷热刺激。到了第二天下午,出血的牙龈基本上已经完全恢复,留下的只有一口的白牙。为此我还嘚瑟了几天。洗掉了二三十年积累下来的牙结石之后,困扰我多日的牙龈出血问题终于解决了。

拖了四年多的驾校学习,终于拿到驾驶证了

另一件值得一提的事情就是,我在6月份终于拿到了驾驶证。困扰我多年的科一练题在二月份一个月的时间里终于解决了,其实找到科一练题的方法之后,科一并没有那么难。如果没有找到方法,去刷题库1000题或者精简题库500题,终究是反复折腾记了忘、忘了记。科二练习了5天,然后去考试,一把过。科三练习了3天,去考试,两把都是挂在终点。科三第二次考试,第一把也是挂在终点,因为终点掉灯之后忘记补灯了,第二把设备故障导致中断,重新补考第二把,终于完美通过了。科三考完回家第二天就阳了。最终,等了一个星期,完全恢复之后,练了差不多一个星期的科四题目,在周六去考了科四,终于拿到了驾驶证,成为了一个合法的驾驶员。

时间过得真快,很多事情已经是物是人非了。估计下半年还会有更多值得回顾的事情。


QQ账号在网页上一键登录的实现原理
Tag 登录, 鉴权, QQ, on by view 106

我们经常可以在网页上看到QQ快捷登录,只需要点击一下QQ图像,不需要账号,不需要密码,不需要扫码,就可以直接登录了。

apnf6a4m

下面简单介绍一下这其中的原理,我们可以在浏览器请求中找到这么一个请求,如下图

4fnj1xbe

可以发现它请求的是127.0.0.1:4301,很明显这个服务是一个本地服务,它就运行在你电脑上。其实它就是你QQ客户端上运行的一个服务,可以看到,请求了这个服务的接口之后,服务响应中设置了相关的cookies,后续,QQ系的网站就可以根据这个cookies进行鉴权了。

所以说,要实现类似于QQ的这种一键登录能力,你需要有个本地客户端,客户端要提供一个http接口,你的网站会请求这个本地客户端,已鉴权的本地客户端收到这个网页上的请求之后,就会将当前登录账号的鉴权cookies植入到浏览器了,这样就成功的实现了一键快捷登录鉴权。这也就是所谓的桌面客户端为浏览器植入cookies的技术。


初学rust,初始化一个C语言结构体
Tag rust, clang, 结构体, on by view 55

最近用到了libc这个包,调用其中的statfs函数,用于查询指定路径挂载的磁盘占用。可以看到

pub fn statfs(path: *const ::c_char, buf: *mut statfs) -> ::c_int;

statfs第二个参数是一个C语言结构体statfs,其定义如下

pub struct statfs {
    pub f_type: ::__fsword_t,
    pub f_bsize: ::__fsword_t,
    pub f_blocks: ::fsblkcnt_t,
    pub f_bfree: ::fsblkcnt_t,
    pub f_bavail: ::fsblkcnt_t,

    pub f_files: ::fsfilcnt_t,
    pub f_ffree: ::fsfilcnt_t,
    pub f_fsid: ::fsid_t,

    pub f_namelen: ::__fsword_t,
    pub f_frsize: ::__fsword_t,
    f_spare: [::__fsword_t; 5],
}

如果用常规的rust初始化方法,必须填充结构体的各字段

let x = statfs{
    ...,
    f_spare: ...,
}

可以看到其中f_spare字段是一个数组,属于复杂类型,用rust的初始化方式,需要一个个字段的填充,而且需要填充为初始值0,将会非常复杂。其实有一种简单方法,使用std::mem::zeroed函数即可。举例如下

fn statfs(&mut self, mount_path: String) -> String {
    let x = CString::new(mount_path.as_bytes()).expect("covert cstring failed");

    let mut info = String::from("");
    let mut statfs_buf = unsafe { std::mem::zeroed() };
    let ret = unsafe { libc::statfs(x.as_ptr(), &mut statfs_buf) };
    if ret == 0 {
        info = format!(
            "{} {} {} {}",
            statfs_buf.f_bsize, statfs_buf.f_blocks, statfs_buf.f_bfree, statfs_buf.f_bavail
        );
    }
    return info;
}

可以看到std::mem::zeroed()方法,直接能够初始化一段0值的内存空间,这段空间具体大小,直接由下一行libc::statfs(...)调用的第二个参数类型决定,由这一行直接能推导出该申请多大的0空间。


初学rust,tokio无法在spawn中使用MutexGuard
Tag rust, tokio, MutexGuard, spawn, on by view 88

最近再次在rust中尝试用tokio::spawn实现类似go语言中goroutine的用法,但是报错了。

#[tokio::main]
async fn main() {
    let cfg = cfg::get_config();
    let client_id = cfg::get_client_id();
    let ws_addr = cfg.agent.as_ref().unwrap().remote_ws_addr.as_ref().unwrap();

    let mut wsc = ws::WebSocketClient::new(ws_addr.clone(), client_id);
    tokio::spawn(wsc.start());

    let monitor_addr = cfg.agent.as_ref().unwrap().remote_ws_addr.as_ref().unwrap();
    let mut monitor_client = monitor::Monitor::new(monitor_addr.to_string());
    monitor_client.start().await;
}

报错位置在这一行 tokio::spawn(wsc.start());,报错内容如下

error: future cannot be sent between threads safely
   --> src/main.rs:19:18
    |
19  |     tokio::spawn(wsc.start());
    |                  ^^^^^^^^^^^ future returned by `start` is not `Send`
    |
    = help: the trait `Sync` is not implemented for `std::sync::mpsc::Receiver<Message>`
note: future is not `Send` as this value is used across an await
   --> src/ws.rs:117:47
    |
115 |                 if let Ok(msg) = rx.recv() {
    |                                  -- has type `&std::sync::mpsc::Receiver<Message>` which is not `Send`
116 |                     println!("Socket Send    : {:?}", msg);
117 |                     let rst = writer.send(msg).await;
    |                                               ^^^^^^ await occurs here, with `rx` maybe used later
...
126 |             }
    |             - `rx` is later dropped here
help: consider moving this into a `let` binding to create a shorter lived borrow

可以看到原因是,我在WebSocketClient中用到了MutexGuard,而MutexGuard不支持被tokio::spawn调用。解释参考这篇文档。实际上我是在另一个结构体Pty中使用了Arc<Mutex<Receiver<Message>>>类型,然后WebSocketClient中又实用了Pty结构体,所以在调用tokio::spawn时报错。

如何解决?别用标准库的Mutex了,用tokio的。而且,我同样发现了Sender,Receiver都在报类似的错误the trait std::marker::Send is not implemented for ...

use std::sync::mpsc::{channel, Receiver, Sender}

// 改为
use tokio::sync::mpsc::channel;
use tokio::sync::mpsc::{Receiver, Sender};
use tokio::sync::Mutex;

channel,Receiver,Sender全部改为tokio版本的,就可以兼容tokio了。


初学rust,多态JSON对象的序列化与反序列化
Tag rust, 序列化, 反序列化, on by view 88

在rust中序列化反序列化JSON我们常用的是serde这个包,但是json中有一种情况就是存在不确定类型的字段,或者说这个字段可能是多种类型;在golang中,我们用interface{}类型来表达,在java中,我们用Object表达,那么在rust应该如何表达呢。直接说结果,用enum来组合类型

比如我定义了一种JSON数据结构,如下

// 第一种
{
    "msg_type": "resize",
    "content": {
        "width": 12,
        "height": 3
    }
}

// 第二种
{
    "msg_type": "ping",
    "content": {
        "ts": 123
    }
}

很明显,两个数据中,content字段表现的类型不一样,在msg_typeresizecontent字段是一种结构体,msg_typepingcontent字段是另一种结构体。我们定义枚举和结构体如下

#[derive(Clone, Debug, Deserialize)]
struct ControlMessage {
    msg_type: String,
    #[serde(default)]
    content: ControlContent,
}

#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(untagged)]
enum ControlContent {
    Empty,
    Resize(ResizeControl),
    Time(TimeControl),
}

impl Default for ControlContent {
    fn default() -> Self {
        Self::Empty
    }
}

#[derive(Clone, Debug, Deserialize, PartialEq)]
struct ResizeControl {
    width: u32,
    height: u32,
}

#[derive(Clone, Debug, Deserialize, PartialEq)]
struct TimeControl {
    ts: u32,
}

可以看到我们定义了两种类型ResizeControlTimeControl,需要注意以下几点:

  1. 要支持反序列化,所有用到的结构体都需要添加Deserialize注解
  2. 枚举中,Empty,Resize,Time这3个都属于枚举的字段名,圆括号()里定义类型,没有定义的表示空
  3. 枚举上,需要添加untagged注解
  4. 要为枚举实现Default::default方法,否则无法被Deserialize注解
  5. 被枚举引用的结构体ResizeControl,TimeControl,以及枚举ControlContent都需要加上PartialEq注解,否则无法在枚举圆括号()中引用

调用反序列化

#[test]
pub fn test_field_deserialize() -> serde_json::Result<()> {
    let a = r#"{"msg_type": "resize", "content": {"width": 12, "height": 3}}"#;
    let v: ControlMessage = serde_json::from_str(a)?;
    println!("v: {:?}", v);

    let b = r#"{"msg_type": "resize", "content": {"ts": 123}}"#;
    let v: ControlMessage = serde_json::from_str(b)?;
    println!("v: {:?}", v);

    Ok(())
}

结果调用成功

running 1 test
v: ControlMessage { msg_type: "resize", content: Resize(ResizeControl { width: 12, height: 3 }) }
v: ControlMessage { msg_type: "resize", content: Time(TimeControl { ts: 123 }) }
test control::test_field_deserialize ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

初学rust,return与无分号结尾区别
Tag rust, return, on by view 114

在rust中,我们在一开始学习rust时,就被告诉,在函数结尾可以不用写return,只需要结尾的那个语句别写分号(;),那么结尾的这个语句计算的值将会作为这个函数的返回值返回。

#[test]
fn test_return_2() {
    println!("none semi: {}", none_semicolon());
}

fn none_semicolon() -> String {
    String::from("hello world")
}

正常输出

running 1 test
none semi: hello world
test test_return_2 ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

这时,我形成一个印象,“结尾语句不加分号,可以当return用”。还有如下的例子

fn test_return_1() -> bool {
    let x = Option::Some(true);

    let y = match x {
        Some(val) => {
            println!("{:?}", val);
            val
        }
        None => false,
    };

    println!("will finish function, y: {:?}", y);

    false
}

我可以看到y变量是bool类型,这时候我以为val这句没有加分号,所以它将值返回给语句块,从而赋值给y,但是,这时候,我改为下面这样的

#[test]
fn test_return_2() {
    println!("none semi: {}", test_return_1());
}

fn test_return_1() -> bool {
    let x = Option::Some(true);

    let y = match x {
        Some(val) => {
            println!("{:?}", val);
            return val;
        }
        None => false,
    };

    println!("will finish function, y: {:?}", y);

    false
}

我发现,return 处就结束了函数并返回。后续的println并没有执行。

running 1 test
true
none semi: true
test test_return_2 ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

总结,位于语句块或者函数体结尾无分号结尾的语句,并不等同于return,它处于语句块结尾时,代表将它的值赋值给语句块所代表的变量,处于函数体结尾时,代表将它的值赋值给函数体所代表的变量(函数返回变量);而return这个函数终止返回的指令,从来都跟无分号结尾的语句无关,而是函数体的反花括号 (}) 的出现所带来的行为。


初学rust,无法在不同的线程中并行读写websocket
Tag rust, 线程, websocket, 读写分离, on by view 181

最近发现一个问题,rust的线程安全机制导致无法实现socket读写分离到两个不同的线程。

先说一下程序的背景,程序是将本地终端pty(cli)拉起,并且将pty的输入输出通过channel对接,并将cli输出的数据经过channel写入到服务端socket,将从服务端socket收取到的数据经另一个channel写入到cli的输入。从而实现远程连接pty。

按照rust的写法,读线程中,在读socket之前需要先锁socket,然后读取,再释放锁;同样,在写线程中,也需要先锁socket,然后写入,再释放锁。这样一来代码应该如下:

连接与初始化代码如下

let (ws_stream, response) =
    connect(Url::parse("wss://ws.postman-echo.com/raw").unwrap()).expect("msg");

println!("Connected to the server");
println!("Response HTTP code: {}", response.status());
println!("Response contains the following headers:");
for (ref header, _value) in response.headers() {
    println!("* {}", header);
}

let socket = Arc::new(Mutex::new(ws_stream));

// init cli
self.pty_start();

let mut me = self.clone();
let skt = socket.clone();
thread::spawn(move || {
    me.watch_socket_read_in(skt);
});
println!("---> watch_socket_read_in");

self.watch_socket_write_out(socket.clone());
println!("---> watch_socket_write_out");

读取方法在一个新起的线程中watch_socket_read_in,如下

fn watch_socket_read_in(&mut self, socket: Arc<Mutex<WebSocket<MaybeTlsStream<TcpStream>>>>) {
    loop {
        let mut skt = socket.lock().unwrap();
        let msg = skt.read_message().unwrap();
        println!("Socket Received: {:?}", msg);
        drop(skt);
        self.tx_in
            .send(msg.clone())
            .expect("send msg into in channel failed");
        println!("send pipe in succ: {:?}", msg);
    }
}

可以看到,不停的从socket读取数据,读取前锁,读取后drop锁。

写入方法在初始化代码所在的主线程中watch_socket_write_out,如下

fn watch_socket_write_out(&mut self, socket: Arc<Mutex<WebSocket<MaybeTlsStream<TcpStream>>>>) {
    let rx = self.rx_out.lock().expect("lock rx out failed");

    for msg in rx.iter() {
        println!("msg from cli -> {:?}", msg);
        let mut skt = socket.lock().unwrap();
        println!("Socket Send    : {:?}", msg);
        skt.write_message(msg).unwrap();
        drop(skt);
    }

    println!("out of socket write out block....")
}

可是,运行的结果却出乎我的意料,运行结果现象是这样的,先是只能够从socket读取到服务端的PING数据,而cli发出的数据经过channel读取出来之后,锁socket,准备发送,但是发现锁socket卡主死锁了,导致无法经socket发送,然后就卡了很久;但是过了一段时间,写socket获取的锁成功了,发了一大堆的数据,然后又轮到读socket卡主,稍后随机的时间后,读socket锁成功,又只能读到PING,如此反复。这种状态的读写,完全不能用,根本实现不了cli与服务端的实时通讯。

分析了一下,应该是socket网络读写是网络通讯,因此读写的锁定socket时长是不确定的且相对耗时算是比较长的,所以导致无法预料是读获取到锁还是写获取到锁,而且这种锁强行将读写串行化了,完全不符合并发读写的要求了。

几经查找,于是采用tokio-tungstenite这个crate替换了tungstenite,因为它可以将WebSocketStream通过split方法分隔为readerwriter,这样一来,读与写就分离开了,在不同的线程中无需对socket加锁。

let (ws_stream, response) =
    connect_async(Url::parse("wss://ws.postman-echo.com/raw").unwrap())
        .await
        .expect("msg");
let (ws_writer, ws_reader) = ws_stream.split();

这样一来,读socketws_reader,写socketws_writer

// socket read
let me = self.clone();
tokio::spawn(async move {
    let mut incoming = ws_reader.map(Result::unwrap);
    while let Some(msg) = incoming.next().await {
        if msg.is_text() {
            println!("Socket Received: {:?}", msg);
            me.tx_in.send(msg).expect("send msg into in channel failed");
        }
    }
});

// socket write
self.watch_socket_write_out(ws_writer).await;
async fn watch_socket_write_out(
    &mut self,
    mut writer: SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>,
) {
    let rx = self.rx_out.lock().expect("lock rx out failed");

    for msg in rx.iter() {
        println!("Socket Send    : {:?}", msg.to_text().unwrap());
        writer.send(msg).await.unwrap();
    }

    println!("out of socket write out block....")
}

可以看到,新线程中读socket,主线程中写socketws_readermap方法后,可以在死循环中阻塞调用next()不断的读取socket中的信息。写socket则从channel中读取到数据,按常规的方法send即可。

r56j2avc

接入tokio-tungstenite解决了这个问题,不过它是基于tokio的,tokio是一个协程库,有自己的运行时,用了tokio的程序起协程后,程序会自动启动若干个线程,类比goroutine,它也是有初始的资源消耗的,比如这个程序只需要4个线程,但是使用了tokio的程序,会有10个线程(如上图),内存占用会明显增多。


初学rust,HashMap的clone
Tag rust, clone, hashmap, on by view 159

在rust中有个常用个方法clone,按字面意思就是克隆。这个函数的作用是对对象进行深度拷贝,生成的新对象与原对象相互独立。

很多常用的类型或者容器类型都支持clone,例如rust中的HashMap也支持clone,我们用一段代码实验一下。

#[test]
fn test_hash_map_clone() {
    let xx: Arc<Mutex<HashMap<String, String>>> = Arc::new(Mutex::new(HashMap::new()));
    let mut mp = xx.lock().unwrap();
    mp.insert("hi".to_string(), "hello".to_string());
    println!("origin: {:?}", mp);
    let mut cp = mp.clone();
    cp.insert("k".to_string(), "v".to_string());
    println!("origin: {:?}", mp);
    println!("cp    : {:?}", cp);
}

输出

running 1 test
origin: {"hi": "hello"}
origin: {"hi": "hello"}
cp    : {"hi": "hello", "k": "v"}
test test_hash_map_clone ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s

上面的测试代码运行结果表示,修改克隆后的对象cp,源对象mp不会发生变化。

那么我们自己定义的类型如何才能支持clone呢?使用#[derive(Clone)]这个指令修饰自定义类型,就会自动支持clone,但是要注意,如果自定义类型结构体里,如果有字段类型不支持clone,将无法通过#[derive(Clone)]指令快速支持clone

自定义类型clone测试如下

#[derive(Debug, Clone)]
struct User {
    name: String,
    age: i32,
}

#[test]
fn test_struct_clone() {
    let mut u1 = User {
        name: "rex".to_string(),
        age: 1,
    };
    println!("origin: {:?}", u1);
    let mut ucp = u1.clone();
    ucp.name = "agnes".to_string();
    ucp.age = 2;
    println!("origin: {:?}", u1);
    println!("cp    : {:?}", ucp);
    u1.age = 3;
    println!("origin: {:?}", u1);
    println!("cp    : {:?}", ucp);
}

运行结果

running 1 test
origin: User { name: "rex", age: 1 }
origin: User { name: "rex", age: 1 }
cp    : User { name: "agnes", age: 2 }
origin: User { name: "rex", age: 3 }
cp    : User { name: "agnes", age: 2 }
test test_struct_clone ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s

同样可以看到,修改clone后的对象,源对象不变,修改源对象,clone后的对象也不变。


golang程序并发读写全局变量,导致空指针异常
Tag golang, 数据竞争, 线程安全, on by view 114

最近将一个golang程序由原本的单线程改为双线程处理日志解析,在生产环境节点上运行发现出现了一个空指针异常,异常信息如下:

[E] 2023/02/27 11:53:25 panic.go:838: parse error:runtime error: invalid memory address or nil pointer dereference,line:Feb 27 11:53:25 xxxxx.site nginx: [xxxxxxxxxxxxxxxxxxx] [27/Feb/2023:11:53:25 +0800] [https] [120.232.31.196:443] [xxxxx.map.xx.com] [39.144.41.41:37647] [200] [30.171.153.132:10000] [200] [3339597] [POST /tr?mllc HTTP/1.1] [621] [152] [xxxxxxx.map.xx.com] [Dalvik/2.1.0 (Linux; U; Android 10; HMA-AL00 Build/HUAWEIHMA-AL00)] [-] [0.008] [0.008] [0.004] [0.008] [46795] [311532943564] [1][1:46:0:0:0:0:0] [ECDHE-RSA-AES128-GCM-SHA256] [TLSv1.2] [r][-] [-] [n] [2358837] [-1] [1677470005.398|5|51|126|-1|-1|126|126|130|-1|-1|130|134|134|134|134#200|200|8|152|120.232.31.196|30.171.153.132:10000|0|0] [POST] [/tr] [mllc] [HTTP/1.1] [39.144.41.41] [-] [-] [-] [-] [-] [-] [-] [-] [-] [-] [169.254.213.29:50937] [0]

修改程序,打印出异常栈 x7s4uzea

发现异常发生在代码133行,代码如下 0zhhoxbp

很明显这里不太可能出现空指针,除非运行到这一行的时候sl对象被置为nil,但是我很确定这里不存在其他线程共享sl的情况,也就是不可能被其他线程置为nil,何况,我这里没有任何操作将sl置为nil,百思不得其解。最后发现key1key2这两个变量是全局变量,全局变量在多线程环境下会存在数据竞争问题。原本定义如下

var (
	uriSep *regexp.Regexp = nil

	key1 = ""
	key2 = ""
)

可以看到key1,key2被定义为全局变量,忘记修改了。修改之后,神奇的发现,空指针异常已经不再存在了。

我在发现这个问题之前,在本地开发服务器上尝试重现,但是一直未能重现出来,估计是我本地qps不够高,所以难以复现,生产环境qps是3w到4w左右,这个空指针异常呈现无规律的隔几秒钟出现一次。

golang中,双协程(绑定在双M和双cpu上)中同时读写一个共享变量导致空指针异常,这种情况我还是第一次遇见,以前遇到这种双协程读写一个共享变量的情况都是数据错乱,并没有空指针。据说这种线程安全、数据竞争导致的空指针异常在C++中也是常见的情况。所以,我在想,这里会不会是因为我将2个协程绑核了,所以在双线程绑双核的情况下更容易复现呢。