多线程并发编程

rust使用1:1线程模型:程序内的线程数和该程序占用的操作系统线程数相等

golang使用的是M:N线程模型:在内部实现了自己的线程模型(绿色线程、协程),程序内部的 M 个线程最后会以某种映射方式使用 N 个操作系统线程去运行

而绿色线程/协程的实现会显著增大运行时的大小,因此 Rust 只在标准库中提供了 1:1 的线程模型,用户根据需要也可以选择 Rust 中的 M:N 模型,这些模型由三方库提供了实现,如tokio

Rust中的多线程

rust中使用thread::spawn创建线程,线程内部的代码使用闭包来执行,main 线程一旦结束,程序就立刻结束,因此需要保持线程的存活,可以使用handle.join,让当前线程阻塞,直到它等待的子线程的结束。使用thread::sleep 会让当前线程休眠指定的时间。

 Rust 无法确定新的线程会活多久,所以在一个线程中直接使用另一个线程中的数据时也无法确定新线程所引用的变量是否在使用过程中一直合法。我们可以使用 move 来将所有权从一个线程转移到另外一个线程,以此来避免该问题

use std::thread;
use std::time::Duration;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        println!("Here's a vector: {:?}", v);
        println!("hi the spawned thread!");
        thread::sleep(Duration::from_millis(1));
    });

    handle.join().unwrap();
    println!("hi the main thread!");
    // 下面代码会报错borrow of moved value: `v`
    // println!("{:?}",v);
}

main线程结束,其余所有线程都会被强行终止。但是如果父线程不是main线程,则该父线程结束后其子线程依然会运行,直到线程自动运行结束或main线程运行结束

线程的创建耗时是不可忽略的,创建一个线程大概需要 0.24 毫秒,随着线程的变多,这个值会变得更大,只有当真的需要处理一个值得用线程去处理的任务时才使用线程。

线程屏障(Barrier)

 在 Rust 中,可以使用 Barrier 让多个线程都执行到某个点后,才继续一起往后执行,例如,以下代码中我们在线程打印出 before wait 后增加了一个屏障,目的就是等所有的线程都打印出before wait后,各个线程再继续执行:

use std::sync::{Arc, Barrier};
use std::thread;

fn main() {
    let mut handles = Vec::with_capacity(6);
    let barrier = Arc::new(Barrier::new(6));

    for _ in 0..6 {
        let b = barrier.clone();
        handles.push(thread::spawn(move|| {
            println!("before wait");
            b.wait();
            println!("after wait");
        }));
    }

    for handle in handles {
        handle.join().unwrap();
    }
}
运行结果:
before wait
before wait
before wait
before wait
before wait
before wait
after wait
after wait
after wait
after wait
after wait
after wait

 Rust 通过标准库和三方库对线程的局部变量进行了支持。在标准库中使用 thread_local 宏可以初始化线程局部变量,然后在线程内部使用该变量的 with 方法获取变量值:

use std::cell::RefCell;
use std::thread;

thread_local!(static FOO: RefCell<u32> = RefCell::new(1));

FOO.with(|f| {
    assert_eq!(*f.borrow(), 1);
    *f.borrow_mut() = 2;
});

// 每个线程开始时都会拿到线程局部变量的FOO的初始值
let t = thread::spawn(move|| {
    FOO.with(|f| {
        assert_eq!(*f.borrow(), 1);
        *f.borrow_mut() = 3;
    });
});

// 等待线程完成
t.join().unwrap();

// 尽管子线程中修改为了3,我们在这里依然拥有main线程中的局部值:2
FOO.with(|f| {
    assert_eq!(*f.borrow(), 2);
});

FOO即是我们创建的线程局部变量,每个新的线程访问它时,都会使用它的初始值作为开始,各个线程中的 FOO 值彼此互不干扰,FOO 使用 static 声明为生命周期为 'static 的静态变量。我们还可以在结构体中或者通过引用的方式使用线程局部变量。

对于第三方库,有牛人开发了一个 thread-local 库,它允许每个线程持有值的独立拷贝。该库不仅仅使用了值的拷贝,而且还能自动把多个拷贝汇总到一个迭代器中。

use thread_local::ThreadLocal;
use std::sync::Arc;
use std::cell::Cell;
use std::thread;

let tls = Arc::new(ThreadLocal::new());
let mut v = vec![];
// 创建多个线程
for _ in 0..5 {
    let tls2 = tls.clone();
    let handle = thread::spawn(move || {
        // 将计数器加1
        // 请注意,由于线程 ID 在线程退出时会被回收,因此一个线程有可能回收另一个线程的对象
        // 这只能在线程退出后发生,因此不会导致任何竞争条件
        let cell = tls2.get_or(|| Cell::new(0));
        cell.set(cell.get() + 1);
    });
    v.push(handle);
}
for handle in v {
    handle.join().unwrap();
}
// 一旦所有子线程结束,收集它们的线程局部变量中的计数器值,然后进行求和
let tls = Arc::try_unwrap(tls).unwrap();
let total = tls.into_iter().fold(0, |x, y| {
    // 打印每个线程局部变量中的计数器值,发现不一定有5个线程,
    // 因为一些线程已退出,并且其他线程会回收退出线程的对象
    println!("x: {}, y: {}", x, y.get());
    x + y.get()
});

// 和为5
assert_eq!(total, 5);

条件变量(Condition Variables)经常和 Mutex 一起使用,可以让线程挂起,直到某个条件发生后再继续执行,调用条件变量的 notify_one 方法来通知主线程继续执行。

有时我们需要某个函数在多线程环境下只被调用一次,例如初始化全局变量,无论是哪个线程先调用函数来初始化,call_once方法都会保证全局变量只会被初始化一次,随后的其它线程调用就会忽略该函数:

use std::thread;
use std::sync::Once;

static mut VAL: usize = 0;
static INIT: Once = Once::new();

fn main() {
    let handle1 = thread::spawn(move || {
        INIT.call_once(|| {
            unsafe {
                VAL = 1;
            }
        });
    });

    let handle2 = thread::spawn(move || {
        INIT.call_once(|| {
            unsafe {
                VAL = 2;
            }
        });
    });

    handle1.join().unwrap();
    handle2.join().unwrap();

    println!("{}", unsafe { VAL });

代码运行的结果取决于哪个线程先调用 INIT.call_once,若 handle1 先,则输出 1,否则输出 2

 线程同步

在多线程间有多种方式可以共享、传递数据,最常用的方式就是通过消息传递或者将锁和Arc联合使用。 

消息通道 

Rust 在标准库里提供了消息通道(channel) ,使用不同的库来满足诸如:多发送者 -> 单接收者,多发送者 -> 多接收者等场景形式

多(单)发送者 -> 单接收者

标准库提供了通道std::sync::mpsc,其中mpscmultiple producer, single consumer的缩写,代表了该通道支持多个发送者,但是只支持唯一的接收者。支持多个发送者也意味着支持单个发送者 ,只是由于子线程会拿走发送者的所有权,我们必须对发送者进行克隆,让每个线程获得一份拷贝才能使用多发送者。对于接收者可以使用for循环进行接收。当子线程运行完成时,发送者tx会随之被drop,此时for循环将被终止。

use std::sync::mpsc;
use std::thread;

fn main() {
    // 创建一个消息通道, 返回一个元组:(发送者,接收者)
    let (tx, rx) = mpsc::channel();
    let tx1 = tx.clone();
    // 创建线程,并发送消息
    thread::spawn(move || {
        let s =String::from("hi from raw tx");
        // 发送一个数字1, send方法返回Result<T,E>,通过unwrap进行快速错误处理
        tx.send(s).unwrap();
        //下面代码将报错,String底层的字符串是存储在堆上,并没有实现Copy特征,当它被发送后,会将所 
        //有权从发送端的s转移给接收端的received,之后s将无法被使用
        //println!("val is {}", s);  
    });

    thread::spawn(move || {
        tx1.send(String::from("hi from cloned tx")).unwrap();
        // 下面代码将报错,因为编译器自动推导出通道传递的值是String类型,那么Option<i32>类型将产 
        //生不匹配错误
        // tx1.send(1).unwrap()
    });
    //使用for循环接收
    for received in rx {
        println!("Got: {}", received);
    }
}

 需注意以下几点:

  • tx,rx对应发送者和接收者,它们的类型由编译器自动推导: tx.send(s)发送了String,因此它们分别是mpsc::Sender<String>mpsc::Receiver<String>类型,需要注意,由于内部是泛型实现,一旦类型被推导确定,该通道就只能传递对应类型的值, 例如此例中非i32类型的值将导致编译错误
  • 接收消息的操作rx.recv()会阻塞当前线程,直到读取到值,或者通道被关闭
  • 需要使用movetx的所有权转移到子线程的闭包中
  • 当发送者关闭或者接收者被关闭时,对方都会接收到一个错误
  • 使用通道来传输数据,一样要遵循 Rust 的所有权规则:若值没有实现Copy,则它的所有权会被转移给接收端,在发送端继续使用该值将报错
  • 多发送者使用let tx1 = tx.clone()克隆,让一个子线程拿走tx的所有权,另一个子线程拿走tx1的所有权
  • 需要所有的发送者都被drop掉后,接收者rx才会收到错误,进而跳出for循环,最终结束主线程
  • 由于两个子线程谁先创建完成是未知的,因此哪条消息先发送也是未知的,最终主线程的输出顺序也不确定,但对于通道而言,消息的发送顺序和接收顺序是一致的,即满足FIFO原则(先进先出)

除了使用recv方法接收消息外,还可以使用try_recv尝试接收一次消息,该方法并不会阻塞线程,当通道中没有消息时,它会立刻返回一个错误:Err(Empty),当发送者被drop掉时,try_recv返回的错误内容是Err(Disconnected),当然数据成功发送时返回ok。

mpsc通道的同步和异步

使用mpsc::channel()创建的都是异步通道:无论接收者是否正在接收消息,消息发送者在发送消息时都不会阻塞 ;异步通道的缓冲无限制,上线主要取决于环境内存的大小,使用mpsc::sync_channel(N)创建同步通道:发送消息是阻塞的,只有在消息被接收后才解除阻塞,即发送者可以无阻塞的往通道中发送N条消息,当消息缓冲队列满了后,新的消息发送将被阻塞(如果没有接收者消费缓冲队列中的消息,那么第N+1条消息就将触发发送阻塞)。

use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
    let (tx, rx)= mpsc::sync_channel(1);

    let handle = thread::spawn(move || {
    println!("首次发送之前");
    tx.send(1).unwrap();
    println!("首次发送之后");
    tx.send(1).unwrap();
    println!("再次发送之后");
    });

    println!("睡眠之前");
    thread::sleep(Duration::from_secs(3));
    println!("睡眠之后");

    println!("receive {}", rx.recv().unwrap());
    handle.join().unwrap();
}
运行结果:
睡眠之前
首次发送之前
首次发送之后
//···睡眠3秒
睡眠之后
receive 1
再次发送之后

 使用异步消息虽然能非常高效且不会造成发送线程的阻塞,但是存在消息未及时消费,最终内存过大的问题。在实际项目中,可以考虑使用一个带缓冲值的同步通道来避免这种风险。所有发送者被drop或者所有接收者被drop后,通道会自动关闭,例如,以下代码会一直阻塞:

use std::sync::mpsc;
fn main() {

    use std::thread;

    let (send, recv) = mpsc::channel();
    let num_threads = 3;
    for i in 0..num_threads {
        let thread_send = send.clone();
        thread::spawn(move || {
            thread_send.send(i).unwrap();
            println!("thread {:?} finished", i);
        });
    }

    // 在这里drop send...
    //drop(send)
    for x in recv {
        println!("Got: {}", x);
    }
    println!("finished iterating");
}

子线程拿走的是复制后的send的所有权,这些拷贝会在子线程结束后被drop,但是send本身却直到main函数的结束才会被drop。由于send自身没有被drop,会导致for循环一直查询接收,永远无法结束,最终主线程会一直阻塞。

一般情况下一个消息通道只能传输一种类型的数据,但如果想要传输多种类型的数据,可以为每个类型创建一个通道,或者使用枚举类型来实现: 

use std::sync::mpsc::{self, Receiver, Sender};

enum Fruit {
    Apple(u8),
    Orange(String)
}

fn main() {
    let (tx, rx): (Sender<Fruit>, Receiver<Fruit>) = mpsc::channel();

    tx.send(Fruit::Orange("sweet".to_string())).unwrap();
    tx.send(Fruit::Apple(2)).unwrap();

    for _ in 0..2 {
        match rx.recv().unwrap() {
            Fruit::Apple(count) => println!("received {} apples", count),
            Fruit::Orange(flavor) => println!("received {} oranges", flavor),
        }
    }
}

 Rust 会按照枚举中占用内存最大的那个成员进行内存对齐,这意味着就算你传输的是枚举中占用内存最小的成员,它占用的内存依然和最大的成员相同, 因此会造成内存上的浪费。

共享内存

除了前面讲的通过消息传递来实现同步,还可以通过共享内存实现 ,例如通过锁和原子操作等并发原语来实现多个线程同时且安全地去访问一个资源。消息传递类似一个单所有权的系统:一个值同时只能有一个所有者,如果另一个线程需要该值的所有权,需要将所有权通过消息传递进行转移。而共享内存类似于一个多所有权的系统:多个线程可以同时访问同一个值。

互斥锁Mutex

Mutex让多个线程并发的访问同一个值变成了排队访问:同一时间,只允许一个线程A访问该值,其它线程需要等待A访问完成后才能继续。Rc<T>RefCell<T>结合,可以实现单线程的内部可变性,Arc<T>与Mutex<T>结合用于多线程内部可变性。 

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    //使用`Mutex`结构体的关联函数创建新的互斥锁实例,通过`Arc`实现`Mutex`的多所有权
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        // 创建子线程,并将`Mutex`的所有权拷贝传入到子线程中
        let handle = thread::spawn(move || {
            // 获取锁,然后deref为`counter`的引用, lock返回的是Result
            let mut num = counter.lock().unwrap();

            *num += 1;
        }); // 锁自动被drop
        handles.push(handle);
    }
    // 等待所有子线程完成
    for handle in handles {
        handle.join().unwrap();
    }
    // 输出最终的计数结果
    println!("Result: {}", *counter.lock().unwrap());
}
运行结果:
Result: 10

 与Box类似,数据被Mutex所拥有,要访问内部的数据,需要使用方法m.lock()m申请一个锁, 该方法会阻塞当前线程,直到获取到锁,因此当多个线程同时访问该数据时,只有一个线程能获取到锁,其它线程只能阻塞着等待,这样就保证了数据能被安全的修改!m.lock()返回一个智能指针MutexGuard<T>:  它实现了Deref特征,会被自动解引用后获得一个引用类型,该引用指向Mutex内部的数据。它还实现了Drop特征,在超出作用域后,自动释放锁,以便其它线程能继续获取锁

lock方法不同,try_lock尝试去获取一次锁,如果无法获取会返回一个错误:Err("WouldBlock"),接着线程中的剩余代码会继续执行,不会被阻塞。

use std::{sync::{Mutex, MutexGuard}, thread};
use std::thread::sleep;
use std::time::Duration;

use lazy_static::lazy_static;
lazy_static! {
    static ref MUTEX1: Mutex<i64> = Mutex::new(0);
    static ref MUTEX2: Mutex<i64> = Mutex::new(0);
}

fn main() {
    // 存放子线程的句柄
    let mut children = vec![];
    for i_thread in 0..2 {
        children.push(thread::spawn(move || {
            for _ in 0..1 {
                // 线程1
                if i_thread % 2 == 0 {
                    // 锁住MUTEX1
                    let guard: MutexGuard<i64> = MUTEX1.lock().unwrap();

                    println!("线程 {} 锁住了MUTEX1,接着准备去锁MUTEX2 !", i_thread);

                    // 当前线程睡眠一小会儿,等待线程2锁住MUTEX2
                    sleep(Duration::from_millis(10));

                    // 去锁MUTEX2
                    let guard = MUTEX2.try_lock();
                    println!("线程 {} 获取 MUTEX2 锁的结果: {:?}", i_thread, guard);
                // 线程2
                } else {
                    // 锁住MUTEX2
                    let _guard = MUTEX2.lock().unwrap();

                    println!("线程 {} 锁住了MUTEX2, 准备去锁MUTEX1", i_thread);
                    sleep(Duration::from_millis(10));
                    let guard = MUTEX1.try_lock();
                    println!("线程 {} 获取 MUTEX1 锁的结果: {:?}", i_thread, guard);
                }
            }
        }));
    }

    // 等子线程完成
    for child in children {
        let _ = child.join();
    }

    println!("死锁没有发生");
}
运行结果:
线程 0 锁住了MUTEX1,接着准备去锁MUTEX2 !
线程 1 锁住了MUTEX2, 准备去锁MUTEX1
线程 1 获取 MUTEX1 锁的结果: Err("WouldBlock")
线程 0 获取 MUTEX2 锁的结果: Ok(0)
死锁没有发生

PS:

     在 Rust 标准库中,一个有趣的命名规则:使用try_xxx都会尝试进行一次操作,如果无法完成,就立即返回,不会发生阻塞。

条件变量condvar和信号量semaphore

Mutex用于解决资源安全访问的问题,条件变量(Condition Variables)用来解决资源访问顺序的问题,它经常和Mutex一起使用,可以让线程挂起,直到某个条件发生后再继续执行。

use std::sync::{Arc,Mutex,Condvar};
use std::thread::{spawn,sleep};
use std::time::Duration;

fn main() {
    let flag = Arc::new(Mutex::new(false));
    let cond = Arc::new(Condvar::new());
    let cflag = flag.clone();
    let ccond = cond.clone();

    let hdl = spawn(move || {
        let mut lock = cflag.lock().unwrap();
        let mut counter = 0;

        while counter < 3 {
            while !*lock {
                // wait方法会接收一个MutexGuard<'a, T>,且它会自动地暂时释放这个锁,使其他线程可以拿到锁并进行数据更新。
                // 同时当前线程在此处会被阻塞,直到被其他地方notify后,它会将原本的MutexGuard<'a, T>还给我们,即重新获取到了锁,同时唤醒了此线程。
                lock = ccond.wait(lock).unwrap();
            }
            
            *lock = false;

            counter += 1;
            println!("inner counter: {}", counter);
        }
    });

    let mut counter = 0;
    loop {
        sleep(Duration::from_millis(1000));
        *flag.lock().unwrap() = true;
        counter += 1;
        if counter > 3 {
            break;
        }
        println!("outside counter: {}", counter);
        cond.notify_one();
    }
    hdl.join().unwrap();
    println!("{:?}", flag);
}
运行结果:
outside counter: 1
inner counter: 1
outside counter: 2
inner counter: 2
outside counter: 3
inner counter: 3
Mutex { data: true, poisoned: false, .. }

信号量用来精准地控制当前正在运行的任务最大数量,即最大并发数,防止服务器资源被撑爆。当前推荐使用tokio中提供的Semaphore实现: tokio::sync::Semaphore

use std::sync::Arc;
use tokio::sync::Semaphore;

#[tokio::main]
async fn main() {
    let semaphore = Arc::new(Semaphore::new(3)); //信号量地容器
    let mut join_handles = Vec::new();

    for _ in 0..5 {
        let permit = semaphore.clone().acquire_owned().await.unwrap();  //申请信号量
        join_handles.push(tokio::spawn(async move {
            //
            // 在这里执行任务...
            //
            drop(permit); //归还信号量
        }));
    }

    for handle in join_handles {
        handle.await.unwrap();
    }
}

 上面代码创建了一个容量为 3 的信号量,当正在执行的任务超过 3 时,剩下的任务需要等待正在执行任务完成并减少信号量后到 3 以内时,才能继续执行。

Atomic原子操作

原子指的是一系列不可被 CPU 上下文交换的机器指令,这些指令组合在一起就形成了原子操作。在多核 CPU 下,当某个 CPU 核心开始运行原子操作时,会先暂停其它 CPU 内核对内存的操作,以保证原子操作不会被其它 CPU 内核所干扰。原子类型是无锁类型,内部使用了CAS(Compare and swap)循环 。

std::sync::atomic包中仅提供了数值类型的原子操作:AtomicBoolAtomicIsizeAtomicUsizeAtomicI8AtomicU16等,而锁可以应用于各种类型,Atomic一般用于一下场景:

  • 无锁(lock free)数据结构
  • 全局变量,例如全局自增 ID, 在后续章节会介绍
  • 跨线程计数器,例如可以用于统计指标

Atomic实现会比Mutex要快很多。Mutex一样,Atomic的值具有内部可变性,无需将其声明为mut 

use std::sync::Mutex;
use std::sync::atomic::{Ordering, AtomicU64};

struct Counter {
    count: u64
}

fn main() {
    let n = Mutex::new(Counter {
        count: 0
    });

    n.lock().unwrap().count += 1;

    let n = AtomicU64::new(0);

    n.fetch_add(0, Ordering::Relaxed);
}

 枚举成员Ordering::Relaxed,用于控制原子操作使用的内存顺序。内存顺序是指 CPU 在访问内存时的顺序,该顺序可能受以下因素的影响:

  • 代码中的先后顺序
  • 编译器优化导致在编译阶段发生改变(内存重排序 reordering)
  • 运行阶段因 CPU 的缓存机制导致顺序被打乱

比如因为cpu缓存导致的内存顺序改变

initial state: X = 0, Y = 1

THREAD Main     THREAD A
X = 1;          if X == 1 {
Y = 3;              Y *= 2;
X = 2;          }

我们来讨论下以上线程状态,Y最终的可能值(可能性依次降低):

  • Y = 3: 线程Main运行完后才运行线程A,或者线程A运行完后再运行线程Main
  • Y = 6: 线程MainY = 3运行完,但X = 2还没被运行, 此时线程 A 开始运行Y *= 2, 最后才运行Main线程的X = 2
  • Y = 2: 线程Main正在运行Y = 3还没结束,此时线程A正在运行Y *= 2, 因此Y取到了值 1,然后Main的线程将Y设置为 3, 紧接着就被线程AY = 2所覆盖
  • Y = 2: 上面的还只是一般的数据竞争,这里虽然产生了相同的结果2,但是背后的原理大相径庭: 线程Main运行完Y = 3,但是 CPU 缓存中的Y = 3还没有被同步到其它 CPU 缓存中,此时线程A中的Y *= 2就开始读取Y,结果读到了值1,最终计算出结果2

 因此Rust 提供了Ordering::Relaxed用于限定内存顺序,该枚举有 5 个成员:

  • Relaxed, 这是最宽松的规则,它对编译器和 CPU 不做任何限制,可以乱序
  • Release 释放,设定内存屏障(Memory barrier),保证它之前的操作永远在它之前,但是它后面的操作可能被重排到它前面
  • Acquire 获取, 设定内存屏障,保证在它之后的访问永远在它之后,但是它之前的操作却有可能被重排到它后面,往往和Release在不同线程中联合使用
  • AcqRel, 是 Acquire 和 Release 的结合,同时拥有它们俩提供的保证。比如你要对一个 atomic 自增 1,同时希望该操作之前和之后的读取或写入操作不会被重新排序
  • SeqCst 顺序一致性, SeqCst就像是AcqRel的加强版,它不管原子操作是属于读取还是写入的操作,只要某个线程有用到SeqCst的原子操作,线程中该SeqCst操作前的数据操作绝对不会被重新排在该SeqCst操作之后,且该SeqCst操作后的数据操作也绝对不会被重新排在SeqCst操作前。

原则上,Acquire用于读取,而Release用于写入。但是由于有些原子操作同时拥有读取和写入的功能,此时就需要使用AcqRel来设置内存顺序了。在内存屏障中被写入的数据,都可以被其它线程读取到,不会有 CPU 缓存的问题。 

最后,在多线程环境中要使用Atomic需要配合Arc

use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::{hint, thread};

fn main() {
    let spinlock = Arc::new(AtomicUsize::new(1));

    let spinlock_clone = Arc::clone(&spinlock);
    let thread = thread::spawn(move|| {
        spinlock_clone.store(0, Ordering::SeqCst);
    });

    // 等待其它线程释放锁
    while spinlock.load(Ordering::SeqCst) != 0 {
        hint::spin_loop();
    }

    if let Err(panic) = thread.join() {
        println!("Thread had an error: {:?}", panic);
    }
}

 

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值