可能是比较智能的高级语言用的习惯了,在突然接触到Rust中比较底层的概念和用法的时候,就十分的不适应,分分钟感觉自己的基础知识已经完全的不知道被自己丢哪儿去了。而且看着Rust中一个泛型套一个泛型的去使用一段内存,把一段内存传来传去,真的是不断的感慨那些“省心”的高级语言帮我们私底下办了多少的事情。但是最令人恼火的是,自以为按照Rust的行为准则编写的程序,被编译器报了无数的错误,而且还一时半会儿想不出来自己到底哪儿错了。
在感慨了一段时间以后,我决定把Rust中这些繁琐的东西,集中记录一下,也为自己以后的那些程序铺铺路。
Box
Box类型在Rust中不说遍地都是,也快差不多了。Box类型主要用于将资源分配在堆上,然后通过Deref和Drop两个特征来管理堆上的资源。
要把资源分配在堆上,只需要使用Box::new()。Box::new()会首先通过转移获取被包装内容的所有权,如果这些资源没有在堆上,那么就会将其转移到堆上。Box类型的变量对于堆上的资源是拥有独占所有权的,如果要想转移所有权,可以使用*操作符解引用。
Box中获取资源的所有权,只能采用move的方法。
借用Box中的值,需要使用Box::borrow()、Box::as_ref()和Box::deref()来获取其指向值的引用。如果需要可变借用,那么就需要使用Box::borrow_mut()、Box::as_mut()和Box::deref_mut()来完成。
关于“堆”
在比较底层的内存管理中,“堆”和“栈”都是不可能绕开的话题。不过好在栈一般都是系统自动管理的,倒不用我们花费额外的精力去应付。但是堆就不一样了,那是靠语言的基层特性和程序的手动控制去完成的。
在被Java和Python之类高级语言的自动垃圾回收“惯坏”以后,堆这个概念就离日常的程序很远了。但是到Rust里以后,堆的管理就需要依靠所有权机制和生命期机制了。
一般说来,在程序中通过赋值、声明等语句新建的内容会被分配到栈上,但是对于结构体、数组、对象等内容,都是会在堆上分配控件存放,然后在栈上放置一个指向堆中地址的指针。
Rc
Rc也类似于Box,也是用于将资源分配在堆上,然后也是通过Deref和Drop两个特征来进行管理的。但是Rc与Box不同的是,Rc是共享所有权的,其中对于被包装的类型采用引用计数法进行存活控制。
Box管理的堆资源,在没有拥有其所有权的指向以后,这块堆内存就会被释放掉。而Rc管理的堆内存资源需要在Rc中的引用计数器归零以后才会被释放掉。
调用Rc::new()也会在堆上分配空间并将被包装的资源保存在里面。但是对于Rc来说,它并不能像Box一样使用*操作符转移堆上这块内存的所有权,只能通过Rc::clone()方法来创建另一个指向这块内存区域的引用。
以下两种使用Rc::clone()的方法效果是一样的。
|
|
借用Rc中的值可以通过Rc::borrow()、Rc::as_ref()和Rc::deref()来创建对于被包装值的不可变借用,可变借用是通过Rc::borrow_mut()、Rc::as_mut()和Rc::deref_mut()来创建的。
Rc将我们所能够创建的结构体连接起来,而且还会试图使用Rc来传递堆内存的共享所有权以规避Rust的所有权机制。但是这样做是错误的,Rust会更加倾向于创建一条单向数据流系统,而不是一个互相交联的系统。
Weak
Weak是Rc的一种特殊形式,它其中所持有的是一个对指定堆内存区域的无所有权引用,也就是说它并不保证它内部所持有的引用,一定可以指向目标区域。Weak在使用的时候可以通过Weak<T>::upgrade()方法升级成一个Option<Rc<T>>类型,如果目标区域已经被清除了,那么Weak::upgrade()方法返回的Option<Rc<T>>类型的实际值就是None。
要创建一个Weak类型的值,可以使用Rc::downgrade()方法。例如以下示例。
|
|
Weak不能自动解引用,因为它不能保证它内部的指向。
Arc
Arc跟Rc基本上就是一样的,只是Rc不是线程安全的,而Arc整合了线程安全的设计。
Cell
Cell是一种内部可变型容器,可以允许在程序运行过程中改变其指向的内容。Cell可以通过Cell::new()来创建一个实例,并获取被包装内容的所有权。如果要从Cell中获得被包装的本体内容,需要使用Cell::get()方法。
Cell::get()方法是采用按位复制的方式取得被包装内容的,所以Cell只适合使用在实现了Copy特征或者不包含引用字段的小型结构体上。
除了可以直接获取Cell包装的本体内容,还可以从Cell中获取被包装的本体的引用,引用是通过Cell::get_mut()来获得的。
既然Cell支持内部可变性,那么改变Cell包装的本体内容,就可以使用Cell::set()实现。调用Cell::set()的时候,也会使Cell获得被包装内容的所有权。
RefCell
虽然Cell能够提供内部可变性的支持,但是并不是所有类型的示例都可以使用Cell包装的,例如没有实现Copy特征的类型就不可以。这种情况下就需要使用RefCell来提供内部可变性支持。RefCell的实例也是通过RefCell::new()来创建,调用以后,RefCell会获取到被包装内容的所有权。
RefCell中被包装的本体内容不能使用类似于Cell::get()来获取所有权,只能通过RefCell::borrow()来借用,如果需要可变借用,就需要使用RefCell::borrow_mut()。
Cow
在Rust程序中,Cow的出现频率也是非常大的,这是一种写时复制的智能指针,主要的存在目的是在读多写少的场景中,减少复制操作,提高性能。Cow是一个枚举体,拥有两个成员元素Borrowed和Ownned,分别表示被包装的内容是借用来的还是转移来的。
在借用一个Cow的实例时,Cow会根据其成员元素的类型来决定如何返回。在借用不可变借用的时候,Cow::deref()和Owned会直接调用borrow方法返回;而Borrowed会直接返回。但是在借用一个可变的Cow实例的时候,Cow::to_mut()和Borrowed会使用clone()把自己转换成Owned,然后再返回一个可变的借用。
从Cow里获取被包装的内容本体,例如调用Cow::into_owned(),如果是Borrowed成员,就会自动调用clone()以后返回;Owned成员则会直接返回。
还可以通过Cow::from()方法创建Cow实例,因为Cow类型实现了大量的From特征,所以在一般情况下,如果from()的参数是一个引用,那么就会创建一个Borrowed成员实例;如果是一个可以获得所有权的内容,那么将会创建一个Owned成员实例。
|
|
在函数之间传递资源
在函数之间传递资源,最需要注意的就是所传递参数的内容和生命期。
生命期其实是这两个内容里最容易被处理的内容,一般来说只要遵照以下几点就可以满足函数参数的生命期要求。
- 对函数的参数进行分类,可以分为主要参数和次要参数。这种分类主要看参数在函数中发挥的作用。
- 对使用函数的位置进行参数作用域分析,划定函数参数所属的作用域。
- 以主要参数为函数的主要生命期定义,观察函数的主要生命期是否能够满足其他次要参数的生命期需求。
- 如果函数主要生命期不能满足其他次要参数的生命期需求,那么就额外定义其他的生命期。
- 所有的生命期必须尽可能的满足最小范围原则,不要随意扩大函数所需要的参数生命期范围。如果几个参数的生命期存在交集,那么就选择其中最小范围的生命期。
参数传递方式的选择就要多很多了,对于不同的函数定义形式和函数调用形式,也有以下这些规律可以遵循。
- 对于可以转移所有权而且在调用函数以后可以不再需要的内容,可以直接采用转移所有权的方式把值转移进函数中。即便程序是多线程的,也可以采用转移所有权的方式,这种方式是绝对线程安全的。
- 函数的参数尽量使用基本类型,例如
i32、&str等。基本类型普遍实现了Copy特征,可以省去设计函数所有权转移的时间。 - 对于在调用函数以后依旧需要使用的值,就需要根据实际情况来选择传递参数的形式了。
- 如果程序是单线程的,不存在跨线程资源传递,那么可以考虑直接传递引用。
- 如果是为了保险起见,传递引用的时候还是通过
Rc包装传递比较好。 - 如果不确定是需要使用引用还是复制,那么可以直接使用
Cow进行包装。
- 如果是为了保险起见,传递引用的时候还是通过
- 如果程序是多线程的,存在跨线程的资源传递,那么就要根据所传递的资源是如何被使用的来确定资源要怎样被传入新线程里面。
- 如果这个资源就是分配给新线程使用的,不与其他子线程共享,那么可以直接使用
Arc包装并传递Arc副本。 - 如果不确定资源是采用引用还是复制,那么可以同时使用
Arc<Cow<>>包装。 - 如果这个资源会被多个子线程共享使用,那么就需要使用
Mutex之类的结构进行包装。例如常常会出现的Mutex<Arc<Cow<T>>这种套娃结构。
- 如果这个资源就是分配给新线程使用的,不与其他子线程共享,那么可以直接使用
- 如果程序是单线程的,不存在跨线程资源传递,那么可以考虑直接传递引用。
- 对于传递可变引用还是传递不可变引用,需要根据函数实现的具体功能来确定。但是一般说来,尽可能的采用不可变数据,对于程序中状态的控制是十分有好处的。
Clone特征,允许从当前结构体的实例创建一个新状态的实例,或者是在发生内部数据变更操作的时候直接返回一个新状态的实例。
多线程控制
要在Rust中使用多线程也十分的简单,可以直接通过调用std::thread模块中的spawn函数,并给它赋予一个函数或者闭包来直接启动一个线程。spawn函数会返回一个JoinHandle类型的实例用于控制线程的运行。
以下是启动一个线程最简单的例子。
|
|
thread::spawn()启动了一个线程,那么这个线程将是一个分离的独立线程,主线程将无法控制它,包括中断、交互以及获取线程运行结果等。如果主线程先于子线程结束,那么子线程将会被强行结束。
Rust中的并发主要是基于线程和闭包构建的,是对系统提供的线程模型的直接抽象。线程拥有自己的栈、堆等结构,所以传递给spawn函数的闭包就必须使用move关键字将闭包运行需要捕获的内容转移到闭包中。
spawn函数中的闭包中使用过的内容,在闭包结束以后都无法再被使用。它们不会再被自动从线程中转移出来。在线程之间共享的内容,必须保证在线程的生命期内,内容始终是有效的。
如果在调用spawn的时候获取了它返回的JoinHandler类型的值,那么就可以使用其中的join()方法让父线程等待子线程的执行。join()方法除了可以让父线程等待以外,还可以从子线程中获取子线程的执行结果。
join()方法的返回值类型是Result<T>,其中携带的错误信息表示在线程中出现了panic。
Send与Sync特征
在涉及多线程编程的地方,经常可以在文档中看到Send特征。Send特征是一个标记特征,不需要特地实现。Send修饰在一个类型上表示这个类型可以被跨线程边界传递,换句话说就是线程安全的。
例如Rc<T>就没有使用Send标记,所以在多线程的情况下使用Rc<T>将会存在资源共享的问题,但线程安全的Arc<T>就标记实现了Send,所以在多线程中就可以放心的使用Arc<T>来完成资源的共享等操作。
另一个会被经常看到的特征是Sync。Sync也是一个标记特征,同样不需要特地实现。Sync特征修饰在一个类型上,表示这个类型可以安全的在线程之间共享引用。如果一个类型T是Sync的,那么它的引用类型&T就必须是Send的,也就是说类型T只有在它的引用类型&T是Send的时候才是Sync的。换句话说,也就是在线程之间传递&T的时候,不能存在未定义的行为,包括数据竞争。
Cell<T>和RefCell<T>都不是Sync的,它提供的内部可变性不能保证&T可能存在的行为。同样的还有Rc<T>。
Mutex
互斥锁Mutex是一个最经典的用来控制线程间资源共享的解决方案。其实Mutex在Rust并发编程中,相当于是一个线程安全的RefCell,也就是Mutex既可以在线程之间共享资源,也可以提供内部可变性。
一个线程在访问Mutex包装的内容之前,必须先获取Mutex加在这个实例上的锁,也就是确保在同一时刻只有一个线程在访问这个资源。调用Mutex中的lock()方法以后,会返回一个LockResult<MutexGuard<T>>类型的智能指针。LockResult是一个枚举,其中包含一个Ok<T>和一个Error值。一般来说,Mutex会阻塞当前的线程,直到获得锁为止。出现Error的情况比较特殊,Error的返回值代表一个panic,在已经持有锁的当前线程中试图重新获取Mutex中的锁,就会抛出这个panic。所以在一般情况下,直接使用unwrap()就可以直接获取到锁,也就是其中的MutexGuard对象。
当获取到的LockResult离开作用域(生命期结束)时,线程获取到的锁就会被释放。
一般在多线程编程中,会使用Arc<T>包裹需需要传递的Mutex对象,因为Mutex对象需要依靠Arc在线程之间传递。
以下是一个简单的在多线程中使用Mutex的示例。
|
|
MutexGuard
MutexGuard也是一个智能指针,在解引用的时候也会直接得到其内部的对象。MutexGuard这个智能指针的功能就是在被包装对象外面包装一层锁,它才是Mutex实际上的执行机构。当MutexGuard被销毁的时候,那么锁也就被解开了。
对MutexGuard进行解引用得到的是其中包装内容的引用,因为MutexGuard<T>实现Deref特征的时候,deref方法的签名是这样的:deref(&self) -> &T,获取可变引用也是一样的。
Condvar
条件变量也是多线程编程中用于控制共享资源的一种更加简单的实现。条件变量利用在多个线程之间的共享变量进行状态的同步,一个线程可以通过设置等待条件变量中所设置的条件成立而挂起,此时线程的挂起将不消耗任何CPU时间,另一个线程则可以通过将条件变量中的条件设置为成立状态来唤醒其他的线程。
所一在条件变量中实际上是包含了两个动作:一个是设置并检测条件,另一个是使条件成立。因为涉及到了在多个线程之间共享变量,所以条件变量经常与Mutex一同使用。
以下是一个使用条件变量控制线程的简单示例。
|
|
通道
通道是多线程编程中另一种形式的信息传递方法,通道不采用数据共享的方式,所以在一般情况下不会产生数据争用。通道可以被理解为是利用队列,在两个线程之间架起了一个通信的桥梁。
一收多发
一收多发的通道是用的比较广泛的,一收多发通常都是用在从多个线程汇集数据使用,而如果两个一收多发通道配合使用的话,还可以形成两个线程交互通信的模式。
一收多发的通道是在Rust的标准库中支持的,定义在std::sync::mpsc包下面。这种一收多发的通道允许发送方和接受方不在一个线程中,通道的发送方将在发送的时候获得所发送信息的所有权,并在接受方执行末尾丢弃信息。接受方收到的信息与发送方发送信息的顺序是完全一致的。
std::sync::mpsc下使用channel()方法定义的通道容量是无限的,sync_channel()定义的通道的容量是有限的。不论哪种通道,如果通道的所有发送方都被丢弃,那么接受方在调用recv()方法的时候,就会返回RecvError;同理当通道的接受方被丢弃以后,任何一个发送方在调用send()方法的时候也也都会返回SendError。由于接受方实现了迭代器,并且接受方会在所有发送方都被丢弃的时候终止,所以直接迭代接受方是一个比较好的选择。
以下是一个简单的应用一收多发通道的示例。
|
|
如果使用的是sync_channel函数创建的带有容量的同步通道,那么此时通道提供的发送方就将是SyncSender类型的,SyncSender类型中的send()方法在通道已满没有空间存放要发送的信息的时候,会自动阻塞发送方线程。
多发多收
Rust的标准库只提供了一收多发类型的通道实现,并没有提供一发多收和多发多收类型的通道实现。但是从具体使用上来说,一发多收和多发多收实际上是类似的。这种支持多个接受方的通道是由一个名为crossbeam_channel的第三方库支持的。
在crossbeam_channel库中,可以直接使用bound()方法创建一个有限容量的通道,使用unbound()方法创建一个无限容量的通道。而其中的发送方Sender和接受方Receiver都是可以被clone出多个的。
多发多收的通道在使用方法上与Rust标准库中提供的一收多发的通道基本上是一样的。如果使用的通道是有限容量的,那么通道的发送方在发送信息的时候,会在通道容量耗尽的时候阻塞发送线程。
send_timeout()方法进行带有等待超时时间的发送,还可以使用send_deadline()方法直接指定最终的等待时间。这两个方法在规定时间内无法完成发送的话,就会返回发送超时错误SendTimeoutError。
线程池的实现
在程序中无限制的直接创建线程会很快的导致系统资源被消耗殆尽,所以线程池的引入就是为了优化多线程的管理,提高多线程条件下线程调度速度的。线程池有效的把线程的创建和执行分开了,使线程不必频繁的创建。
所以要实现一个线程池,其实就是提前准备了一组空闲的线程,然后通过接受要执行的闭包函数的方式,将所要执行的函数发送给空闲的线程执行。在一般的线程池实现中,都是采用一个加了锁的队列来作为向各个线程派发任务的核心,但是在Rust里,可以直接借用通道来完成这个任务。
既然要使用任务分发的方式,那么就必须首先确定所要分发的任务的类型。分发给各个线程执行的任务应该必须满足以下条件。
- 任务都是普通的函数,只能够执行一次,其中所捕获的内容在所要执行的任务结束以后就全部释放了,所以任务的主类型是
FnOnce()。 - 任务必须能够被跨线程发送,所以任务的类型也必须实现
Send特征。 - 由于各个线程都是在全局执行的,而且是一个完整的独立的结构,所以传入的参数的生命期也就不能太短,也就是说需要是
'static。
这样一来,任务的类型就是FnOnce() + Send + 'static了。在定义任务类型的时候,需要再给这个类型包装一层,所以任务的类型定义就变成了下面的样子。
|
|
另一种任务类型的定义
还可以使用只存在于nightly版本中的FnBox来定义任务的类型,为了保持不适用nightly版本的兼容性,可以手工定义一个FnBox。
|
|
使用这种定义方法,会将传递的函数使用Box包装一层,就不再要求Job中传递的内容必须是'static的了。
指令类型定义
有了任务的类型,接下来就需要定义一套用于控制线程池中线程工作的指令类型了。拜Rust中灵活的枚举类型所赐,用于控制线程工作的指令类型可以被定义的十分简单。
例如以下的示例中,可以完成任务的分发和线程的结束动作。
|
|
线程池的基本结构
有了任务类型和指令类型,现在就可以定义线程池的实现了。
|
|
首先要完成创建线程池的功能,创建线程池需要初始化全部工作线程。
|
|
然后就是完成将要执行的任务发送给线程池了。这一部分可以定义一个execute方法来实现。
|
|
基于条件变量构建的线程池
基于条件变量构建的线程池可以避免多个线程同时对通道的接收端加锁的问题(但是依旧还存在共享资源加锁的问题),而且也不再需要使用通道这样的复杂数据结构,只需要一个队列即可。要使用条件变量,就再需要一个条件变量依赖的条件状态。
|
|
然后就是构建这个线程池的构造方法。
|
|
最后就是给这个线程池实现加入排列要执行的任务进入执行队列的方法。
|
|
剩下缺少的就是如何让线程池停下来了,这可以通过为线程池实现Drop特征来实现。