【C++11】线程库/异常

一:线程库

    1.1:线程库(thread)

          1.1.1:为什么要有线程库

          1.1.2:thread库中的成员函数

          1.1.3:线程函数参数

    1.2:互斥锁(mutex)

          1.2.1:为什么要有互斥锁

          1.2.2:C++11中的互斥锁

    1.3:原子操作(atomic)

    1.4:条件变量(condition_variable)

二:异常

    2.1:C语言中处理错误的方式

    2.2:异常的概念

    2.3:异常的具体用法

    2.4:异常的重新抛出

    2.5:异常的安全和规范

    2.6: 异常的优缺点

一:线程库

    1.1:线程库(thread)

        1.1.1:为什么要有线程库

关于多线程,我们在学习Linux中重点学习过,,Windows中的线程我们多多少少也有点了解,但是我们能明显的感觉到二者在使用方式上的差别,先不说别的,二者在创建与销毁线程时的方式就非常的不同(Linux下的线程通过在进程中调用pthread_create()函数来创建线程,通过pthread_exit()函数来退出线程,而Windows下的线程则是通过CreateThread()函数来创建线程,通过ExitThread()函数来退出线程);这是为什么呢?

因为Linux下的pthread线程库是POSIX(可移植操作系统接口)的线程标准,一般用于Unix_likePOSIX 系统,而Windows并没有支持POSIX,而是自己搞了一套提供线程相关的接口!!!也就是说,Linux与Windows下的多线程库是不同的,那么Linux下写的多线程程序就不能与Windows下的多线程程序相互适应,可移植性就比较差!那如果我们想让自己写的程序在Linux下能运行,Windows下也能运行,该怎么办呢???

//C++11之前,如何让Linux与Windows支持同一份多线程程序?
//通过条件编译来实现!
int main()
{
#if _WIN32//_WIN32是Windows特有的宏,
	//如果是在Windows下,那么使用Windows下多线程接口!
	//CreateThread//创建线程,然后。。。。。

#else//如果不在Windows下,那么我们使用Linux下多线程接口!
	pthread_create//创建线程,然后。。。。。

#endif
	return 0;
}

在C++11之前,我们可以通过条件编译,写两份调用不同线程接口的程序来实现!但是这样会非常的麻烦,繁琐!

所以C++11新增封装了一个thread库,可以对线程进行支持,使得C++在并行编程时不需要依赖第三方库,只要所使用的编译器支持C++11,那么无论在什么平台下,都可以使用一样的线程库,提高了程序跨平台的兼容性,同时又避免使用特定于某一平台的函数和接口。

那么thread库是如何做到可以跨平台使用呢?---其实和之前一样,通过条件编译来实现,Linux下用Linux的线程接口,Windows下用Windows的线程接口,只不过条件编译以及调用不同线程接口的过程不需要我们自己编写,而是thread库提前帮我们封装好了!同时这个thread库被封装成了一个面向对象的类!!!

1.1.2:thread库中的成员函数

既然thread库被封装成了一个类,那么我们先来看看这个thread库中比较重要的默认成员函数!

构造函数和移动赋值:

我们可以看到,thread库中有三个构造函数(同时还有一个被delete的拷贝构造(即不允许拷贝构造)),那这些构造函数该如何使用呢?                 

 首先我们先看带有可变参数模板的构造函数

template <class Fn, class... Args>
explicit thread (Fn&& fn, Args&&... args);

 其中,fn:是可调用对象,如函数指针、仿函数、lambda表达式、被包装器包装后的可调用对象等。

        args:可变参数包,传递fn这个可调用对象所需要的如干类型的形参!!!

void func(int x, int n)
{
	for (int i = 0; i < x; ++i)
	{
		cout << n << ":" << i << endl;
	}
}

int main()
{
	//我们可调用对象可以是函数指针,同时,参数可以是任意多个,参数的属性可以是左值/右值!!!
	thread t1(func, 10, 1+3);//1+3是右值
	//我们可调用对象也可以是lambda表达式!
	thread t2([](int x,int n)
		{
			for (int i = 0; i < x; ++i)
			{
				cout << n << ":" << i << endl;
			}
		}, 20, 2);
	//main主线程需要将这两个线程回收,否则将会崩溃!
	t1.join();
	t2.join();
	return 0;
}

 这就是使用使用带有可变参数模版的构造函数构造线程的过程而相较与Linux下的pthread_create( 在Linux中,pthread_create创建线程时,线程的调用对象必须是void**类型的,且当要传递的参数>1时,需要将多个参数封装成一个结构体,然后转成void*类型传递过去,再在线程调用对象中将void*强转成结构体,才能拿出多个参数!非常的麻烦,繁琐,下面给出pthread_create函数的具体形式:)

而使用模板构造线程就非常方便,不仅仅可以接受多种类型的可调用对象,而且也可以传递任意多个参数!!!

移动构造:

 thread提供了一个移动构造函数,能够用一个右值线程对象(匿名对象/临时对象)来构造一个线程对象。具体如下所示:

void func(int x, int n)
{
	for (int i = 0; i < x; ++i)
	{
		cout << n << ":" << i << endl;
	}
}
int main()//移动构造:
{
	thread t3 = thread(func, 10, 1);
	thread t4 = thread([](int x,int n)//生成的匿名对象移动拷贝给t4这个线程! 
		{
			for (int i = 0; i < x; ++i)
			{
				cout << n << ":" << i << endl;
			}
		},10,2);
	t3.join();
	t4.join();
}

 至于无参的构造函数我这里需要将其与移动赋值搭配在一起,我们先看看默认的operator=:

 这里有一个问题能更好的体验出无参构造和移动赋值:如果我要创建n个线程,每一个线程打印m次(n,m是输入的,且m不同),该如何实现?

具体解答思想:
1:用容器(例如vector)创建n个线程,vector有一个构造常数个无参对象的构造函数!而正好thread支持构造无参对象的构造函数,只不过构建出来的对象什么都不干,那么此时我们就可以通过这个vector的构造函数创建n个无参的线程对象!

2:然后再通过thread的移动赋值将一个个右值对象(匿名对象)移动给构建的n个无参的线程对象,即可完成这个问题!!! 具体如下所示:

//无参构造+移动赋值
int main()
{
	int n = 0;
	int m = 0;
	cin >> n;//要创建n个线程,
	vector<thread> vtids(n);//此时我们就创建了多个无参的线程对象!
	vector<int> num;
	for (int i = 0; i < n; ++i)//每一个线程打印不同的m次
	{
		cin >> m;
		num.emplace_back(m);
	}
	for (int i = 0; i < n; ++i)
	{
		//将lmabda表达式形成的匿名对象移动赋值给vector中的无参线程对象!
		vtids[i] = thread([i,num]()
			{
				for (int j = 0; j < num[i]; ++j)
				{
					cout << i << ":" << j << endl;
				}
			});
	}
	//最后将多个线程回收:
	for (auto& e : vtids)
	{
		e.join();
	}
	return 0;
}

 这样,就完美的使用了无参构造和移动赋值,

其实,我们在实现线程池的时候就用到了类似的方法,就是先创建一批线程,但一开始这些线程什么也不做,只是等待任务的到来,当有任务到来时再让这些线程去处理这些任务!

其他成员函数:

至于其他的成员函数,如join(回收线程),detach(分离线程)等成员函数具体如下所示:基本上和我们在Linux学习时的pthread_join,pthread_detach等线程接口差不多,

这里joinable我们需要注意一下:什么时候线程算没有执行完毕,什么时候算线程已经结束了呢?--------如果是以下任意情况,则线程无效:
1:采用无参构造函数构造的线程对象(无参对象没有关联任何线程)
2:线程对象的状态已经转移给其他线程对象(线程通过移动语句交付给另一个线程,此时原线程就无效了)
3:线程已经调用jion或者detach结束(都已经回收或者分离(分离的线程会自己join),肯定就执行完了)


同时,获取线程ID的接口和我们Linux学习中的pthread_self()也有一些不一样!!!就拿我们上面无参构造+移动构造的例子举例,如果我想在可调用对象打印的时候获取线程ID该怎么办:

int main()
{
	int n = 0;
	cin >> n;//要创建n个线程,
	vector<thread> vtids(n);//此时我们就创建了多个无参的线程对象!
	for (int i = 0; i < n; ++i)
	{
		//将lmabda表达式形成的匿名对象移动赋值给vector中的无参线程对象!
		vtids[i] = thread([i]()
			{
				int m = 5;
				for (int j = 0; j <m; ++j)
				{
                    //此时这样写会报错,因为线程对象都没有创建好呢,怎么可能用线程对象去调用!
                    //cout << vtids[i].get_id() << endl;
					cout << i << ":" << j << endl;
				}
				cout<<endl;
			});
        //我们只能在线程创建好后,在这里通过线程去调用get_id,从而获取线程ID
		cout << vtids[i].get_id() << endl;
	}
	//最后将多个线程回收:
	for (auto& e : vtids)
	{
		e.join();
	}
	return 0;
}

 但是,我们通常需要在线程关联的线程函数中获取线程ID,而上述这种创建好线程对象,再用线程对象来调用get_id函数获取线程ID的方式就不行了,我们需要换一种!而正好线程库中其他的解决方法:

int main()
{
	int n = 0;
	cin >> n;//要创建n个线程,
	vector<thread> vtids(n);//此时我们就创建了多个无参的线程对象!
	for (int i = 0; i < n; ++i)
	{
		//将lmabda表达式形成的匿名对象移动赋值给vector中的无参线程对象!
		vtids[i] = thread([i]()
			{
				int m = 5;
				for (int j = 0; j <m; ++j)
				{
                    //此时我们就可以用this_thread命名空间中的get_id函数
                    //在构造线程对象时打印线程的ID!!!
					cout << this_thread::get_id() << ":" << j << endl;
				}
				cout<<endl;
			});
	}
	//最后将多个线程回收:
	for (auto& e : vtids)
	{
		e.join();
	}
	return 0;
}

 除了get_id()这个函数之外,这个命名空间里面还封装了另外三个函数,yield()和sleep_until()使用的比较少,slee_for()就像在Linux中使用sleep函数那么常用,至于怎么使用,用的时候直接去Reference - C++ Reference 看例子即可!这里我就不过多的介绍了;

综上,我们对线程的成员函数做出一些总结与注意事项:

1. 线程是操作系统中的一个概念,线程对象可以关联一个线程,用来控制线程以及获取线程的;

2. 当创建一个线程对象后,没有提供线程函数,该对象实际没有对应任何线程。状态!

3. 当创建一个线程对象后,并且给线程关联线程函数,该线程就被启动,与主线程一起运行。线程函数一般情况下可按照以下三种方式提供:

     函数指针,ambda表达式,函数对象!

4. thread类是防拷贝的,不允许拷贝构造以及拷贝赋值,但是可以移动构造和移动赋值,即将一个线程对象关联线程的状态转移给其他线程对象,转移期间不影响线程的运行。

1.1.3:线程函数参数

我们知道,每一个线程都有自己独立的栈帧!而线程函数的参数是以值拷贝的方式拷贝到线程栈空间中的,因此:即使线程参数为引用类型,在线程中修改后也不能修改外部实参,因为其实际引用的是线程栈中的拷贝,而不是外部实参!!!具体如下所示:

void ThreadFunc1(int& x)
{
	x += 10;
}

//线程函数参数
int main()
{
	int a = 10;
	cout << a << endl;
	// 在线程函数中对a修改,不会影响外部实参,因为:线程函数参数虽然是引用方式,
	//但其实际引用的是线程栈中的拷贝(线程之间各自拥有独立的栈)!
	thread t1(ThreadFunc1, a);
	t1.join();
	cout << a << endl;
    return 0;
}

如果想要通过形参改变外部实参时,我们该如何做呢???有两种方式,借助std::ref()函数 或者 对要修改的数的地址进行拷贝,具体如下所示:


这样就可以修改 主线程main函数中定义的传给线程的参数!!!

    1.2:互斥锁(mutex)

      1.2.1:为什么要有互斥锁

C++11标准新增了互斥锁(mutex)以及相关的同步原语。互斥锁是多线程编程中的一种同步机制,它可以将某一段代码的访问限制在同一时刻只能由一个线程执行,以避免不同线程之间的竞争和冲突。

多线程最主要的问题是共享数据带来的问题(即线程安全)。如果共享数据都是只读的,那么没问题,因为只读操作不会影响到数据,更不会涉及对数据的修改,所以所有线程都会获得同样的数据。但是,当一个或多个线程要修改共享数据时,就会产生很多潜在的麻烦,比如。如果我们用两个线程对同一个全局变量++,看看会发生什么?具体如下所示:

我们看到, 同一个线程执行函数中,全局变量的地址是相同的,但是参数n的地址是不同的,为什么呢???---------因为线程之间静态区,常量区,堆区等是线程共享的,但是寄存器和栈帧是独立的!这也印证了我们之前所说的每一个线程都有自己独立的栈帧!!!

同时我们也可以看到运行后的结果确实是100+200=300!符合我们的预期,运行的没有错误呀!

其实不然,因为当多个线程对同一个全局变量进行++时,可能出现同时++的情况,那么最终的结果肯定小于等于预期的结果,这里之所以等于300,是因为数太小,CPU运行的太快了!!!那么我们加大数字再看看,具体如下所示:

我们此时就可以看到,最终运行的结果确实<=预期的结果(也就是说,有相当一部分是两个线程同步重复++的),并且每一次运行的结果都是大不相同的(之所以每一次运行结果不同是跟指令和系统状况相关)!!!

那么线程安全的根本原因到底是什么呢?我以在Linux学习时的笔记对其说明:

在Linux学习笔记中,我们是以对一个全局变量做--操作时进行分析,我们现在这里是++操作,但是不影响,其本质都是一样的!!!

那我们该如何解决这种并发问题,保护共享资源(临界资源)呢???--------通过加锁(互斥锁)实现,通过加锁,可以让某一段代码的访问限制在同一时刻只能由一个线程执行,防止并发问题的出现!!!

1.2.2:c++11中的互斥锁

在C++11中,Mutex总共包了四个互斥量的种类:

下面,我们分别认识认识这四种互斥锁类!

mutex(h互斥锁类)

C++11提供的最基本的互斥量,先看看这个互斥锁类的构造函数:

 如上所示,互斥锁只支持无参的构造函数,不允许拷贝构造(该类的对象之间不能拷贝,也不能进行移动!);

同时mutex这个类的成员函数并不多,具体如下所示:

 构造函数我们已经看过了,剩下的四个成员函数中,native_handle这个不经常用,剩下的三个成员函数是最常用的!!!

注意:

线程函数调用lock()时,可能会发生以下三种情况:
1:如果此时该互斥量没有被锁住,则调用线程就可以将互斥量锁住,直到调用 unlock之前,该线程一直拥有该锁!
2:如果互斥量已经被其他线程锁住,那么当前调用的线程则会被阻塞住!
3:如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)!


线程函数调用try_lock()时,可能会发生以下三种情况:
1:如果当前互斥量没有被其他线程占有,则该线程锁住互斥量,直到该线程调用 unlock释放互斥量!
2:如果当前互斥量被其他线程锁住,则当前调用线程返回 false,而并不会被阻塞住!
3:如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)!

那么接下来,我们就尝试通过mutex(互斥锁类)解决一开始并发++全局变量的问题,具体如下所示:


 

 我们可以看到,当我们定义一个局部的锁时,结果还是不对,是因为每一个线程都有自己独立的栈,如果此时你定义局部的锁,那么每一个线程里面都存在一个自己的锁,那么这个锁就毫无意义,但是当我们定义的是全局的锁,那么执行++操作时,只有持有锁的线程才能操作,从而避免并发问题的参数!!!

注意:锁放置的位置也值得我们研究,但是在探讨这个问题之前,我们需要先明白一个概念,什么是临界区?

所谓临界区,就是指多个线程同时访问共享资源(如全局变量、文件等)的一段代码(切记,是代码);例如,我们把加锁解锁放到了++x的前后,那么此时临界区中的代码就是++x,如果我们把加锁解锁放到for循环外,那么整个for循环就是临界区!!!

这里就存在着锁放置位置的不同,效率的不同的问题,是把锁加锁解锁在++x(此时两个线程可以看作并行)这个临界区的效率高,还是加锁解锁在for循环(此时两个线程可以看作串行)这个临界区的效率高呢???

这里我就不举例说明了,直接给出结论:并行并不一定比串行快,这里主要看谁消耗的时间短!只要是看并行时临界区(频繁加锁解锁+切换上下文+执行代码)所消耗的时间和串行时临界区(加锁解锁+执行代码)所需要的时间哪一个短,谁短谁效率就相对较高!!!

上面是用函数指针的方式来实现线程的执行函数的,其实在C++11之后,我们经常会使用lambda表达式作为线程的执行函数,具体如下所示:

/// 

(recursive_nutex)递归互斥锁类

递归互斥锁类的成员函数和互斥锁类的成员函数相同,都有(lock,try_lock,unlock等),那如果我们递归++时进行多线程,是不是也有多线程的问题?答案是肯定的,那么此时我们即可通过加锁(mutex)的方式进行解决:

我们确实看到通过加锁解锁(mutex)能解决并发问题,但是临界区只有++x这一句代码,而此时我们对其频繁的加锁解锁 是不是有点降低效率了,我们能不能在递归之后再解锁呢?如果递归之后解锁不可行,我们该怎么办呢?

我们可以看到,用mutex锁,在递归之后解锁是不可行的,会引发死锁问题(可能会重复申请已经申请到但自己还未释放的锁);所以此时我们可以利用递归互斥锁(recursive_mutex),递归互斥锁允许同一个线程对互斥量多次上锁(即递归上锁),来获得对互斥量对象的多层所有权,释放互斥量时需要调用与该锁层次深度相同次数的 unlock(),除此之外,递归互斥锁(recursive_mutex)的特性和 互斥锁(mutex) 大致相同!!!

(timed_mutex)定时互斥锁类和(recursive_timed_mutex)递归定时互斥锁类

定时互斥锁类(timed_mutex)比互斥锁类(mutex) 多了两个成员函数,try_lock_for(),try_lock_until() 。

try_lock_for():
接受一个时间范围,表示在这一段时间范围之内线程如果没有获得锁则被阻塞住(与mutex 的 try_lock() 不同,try_lock_for如果被调用时没有获得锁则直接返回false),如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间内还是没有获得锁),则返回 false。
try_lock_until():
接受一个时间点作为参数,在指定时间点未到来之前线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间内还是没有获得锁),则返回 false。

递归定时互斥锁类(recursive_timed_mutex)就是递归互斥锁类(recursive_mutex)和定时互斥锁类(timed_mutex)的结合,递归定时互斥锁类既支持在递归函数中进行加锁操作,也支持定时尝试申请锁!!!

这四种锁 我们重点学习.使用互斥锁类(mutex)和认识递归互斥锁类(recursive_mutex),至于后面两种互斥锁,用的比较少,这里就不做介绍了,如果到用的时候直接去官网看看例题!

lock_guard和unique_lock

有时候,在我们使用互斥锁进行加锁解锁的过程中,可能会因为在解锁之前提前return 或者出错误,导致没能成功解锁,函数就结束了,那么此后线程再申请这个锁时都会被阻塞住,从而引发死锁问题!!如下所示:

 因此,在使用互斥锁的时候,如果控制不好,出错时不能及时的解锁,就很容易引发死锁问题,那我们该如何避免呢???

为了解决此类问题,有的大佬就通过实现一个类来解决这种死锁问题(主要运用RAII思想),具体类如下所示:

template <class lock>
class LockGuard
{
public:
	LockGuard(lock& lk)//必须传引用传参,因为mutex锁不支持拷贝构造,赋值,移动语句等,
		:_lock(lk)
	{
		_lock.lock();//构造函数时加锁
	}
	~LockGuard()
	{
		_lock.unlock();//析构函数时解锁
	}
private:
		lock& _lock;//同时,这里需要引用,因为构造函数时不支持拷贝!
};
mutex mtx;
void func()
{
	//那么此时,我们就不用手动的加锁解锁,
	//当程序出现错误退出或者return时,即出了这个函数的作用域,
	//那么此时,会知道调用析构函数将锁解开!!!
	LockGuard<mutex> lock_guard(mtx);
	//..........
	int* ptr = (int*)malloc(sizeof(int) * 10);
	if (ptr == nullptr)
	{
		exit(-1);
	}
	if (rand() % 3 == 0)
	{
		return;
	}
	//.......
}

此时,当这个类定义的对象在哪一个作用域下,那么这个作用域中的代码就受锁的保护,当出来这个作用域,锁会自动调用析构函数进行解锁,从而避免形成死锁问题!!!

同时,如果你不想让这个类对象随着整个函数的作用域创建和销毁,可以通过定义匿名的局部域来控制这个类对象的生命周期。比如:

void func()
{
	//构建一个匿名的作用域,在这个作用域内定义的类对象,出这个匿名作用域即调用析构函数解锁!!!
	{
		LockGuard<mutex> lock_guard(mtx);
		//..........
		int* ptr = (int*)malloc(sizeof(int) * 10);
		if (ptr == nullptr)
		{
			exit(-1);
		}
		if (rand() % 3 == 0)
		{
			return;
		}
	}
	//从而不影响这个作用域之外的行为!!!
	//。。。。。。。。。
}

这个LockGuard类并不需要我们自己实现,C++11之后支持了这个类,具体如下所示:

可以看到,lock_guard这个类是一个模板类,其中,它的成员函数只有构造函数和析构函数,即构造函数加锁,析构函数时解锁,用于管理互斥锁的加锁和解锁。

但是lock_guard这个类有一个缺点:太单一,它只能构造函数加锁,析构函数解锁,用户没有办法对该锁进行控制!!!

所以为此。C++11又提供了unique_lock!!!


 

与lock_guard类似,unique_lock类模板也是采用RAII的方式对锁进行了封装,并且也是以独占所有权的方式管理mutex对象的上锁和解锁操作,即其对象之间不能发生拷贝。在构造(或移动(move)赋值)时,unique_lock 对象需要传递一个 Mutex 对象作为它的参数,新创建的unique_lock 对象负责传入的 Mutex 对象的上锁和解锁操作。使用以上类型互斥量实例化unique_lock的对象时,自动调用构造函数上锁,unique_lock对象销毁时自动调用析构函数解锁,可以很方便的防止死锁问题。
与lock_guard不同的是,unique_lock更加的灵活,提供了更多的成员函数:
1:上锁/解锁操作:lock、try_lock、try_lock_for、try_lock_until和unlock
2:修改操作:移动赋值、交换(swap:与另一个unique_lock对象互换所管理的互斥量所有权)、释放(release:返回它所管理的互斥量对象的指针,并释放所有权)
3:获取属性:owns_lock(返回当前对象是否上了锁)、operator bool()(与owns_lock()的功能相同)、mutex(返回当前unique_lock所管理的互斥量的指针)
 

那么unique_lock的应用场景是什么样的呢?

如果只是单纯的构造函数加锁,析构函数解锁时,lock_guard和unique_lock没有区别!

但是如果我们想在构造函数加锁,析构函数解锁之间,做一些其他的操作(这个操作不需要加锁),那么我们就需要先将锁解开,做完这些操作再将锁加回来,此时lock_hguard就显得捉襟见肘了,unique_lock就正好解决这种问题,具体如下所示:

mutex mtx;
void func1()
{

	{
		unique_lock<mutex> unlock(mtx);
		//..........
		int* ptr = (int*)malloc(sizeof(int) * 10);
		if (ptr == nullptr)
		{
			exit(-1);
		}
		//当在构造函数加锁,析构函数解锁之间调用func2!
		mtx.unlock();//需要先解锁
		func2();//调用func2;
		mtx.lock();//在加锁!
		if (rand() % 3 == 0)
		{
			return;
		}
	}//然后出这个匿名作用域后再调用析构函数解锁!

	//从而不影响这个作用域之外的行为!!!
	//。。。。。。。。。
}

1.3:原子操作(atomic)

原子操作:是指不能被中断的操作,即针对某个变量的读取、修改、写入的操作不能被其他线程干扰,必须“原封不动”地执行完毕,即不可被中断的一个或一系列操作。

在多线程编程中,为了保证线程安全和避免数据竞争,通常会使用互斥锁等同步机制来保护共享变量。

但是互斥锁等同步机制的缺点是:在多线程高并发的情况下,会增加线程之间的竞争和上下文切换的开销,如果对锁的控制出现错误,甚至会出现死锁等问题,从而影响程序的执行效率。

所以,C++11中引入了原子操作。C++11引入的原子操作类型,使得线程间数据的同步变得非常高效。原子操作类型如下所示:

 此时,通过atomic,就不需要对原子类型变量进行加锁解锁操作,线程能够对原子类型变量互斥的访问,我们以之前互斥锁学习时的例子为例:

更为普遍的,我们可以使用atomic类模板,定义出需要的任意原子类型。

那么,上面的atomic_int就可以改成以下的形式:

int main()
{
	int n = 10000;
	atomic<int> x = 0;//自己通过atomic模板创建一个atomic类型
	//mutex mtx;
	thread t1([&, n]()//混合捕捉:除了n传值捕捉,其他的(x,mtx锁)全部传引用捕捉!!!
		{
			//mtx.lock();
			for (int i = 0; i < n; ++i)
			{
				++x;
			}
			//mtx.unlock();
		});
	thread t2([&, n]()
		{
			//mtx.lock();
			for (int i = 0; i < n; ++i)
			{
				++x;
			}
			//mtx.unlock();
		});
	t1.join();
	t2.join();
	cout << x << endl;
	return 0;
}

同时原子类型不仅仅支持原子的++操作,还支持原子的--、加一个值、减一个值、与、或、异或操作。

但是这里有一个小问题,就是我们自己定义了一个atomic<int>的原子类型,那如果我再将它传递给一个int类型的参数或者按照int类型进行打印时,是会有可能出错的,为了解决这个问题,atomic类中存在一个load函数,可以读到atomic类模板中参数的类型,具体如下所示:

注意:原子类型通常属于"资源型"数据,多个线程只能访问单个原子类型的拷贝,因此在C++11中,原子类型只能从其模板参数中进行构造不允许原子类型进行拷贝构造、移动构造以及operator=等,为了防止意外,标准库已经将atmoic模板类中的拷贝构造、移动构造、赋值运算符重载默认删除掉了。

1.4:条件变量(condition_variable)

条件变量(Conditional Variable)是一种线程同步机制,用于在多线程环境中实现线程间的等待和通知机制。主要用于解决线程间的同步问题,它允许一个或多个线程等待某个条件成立,并在条件成立时通知其他线程或进程继续执行。

条件变量不是线程安全的,所以通常与互斥锁(mutex)一起使用,以实现线程间的安全访问和同步。条件变量提供了一种阻塞等待的方式,当线程执行到等待条件变量的代码时,线程会被阻塞在条件变量上,并且等待其他线程通知。当其他线程或进程结束后会通知在条件变量上等待的线程,此时被阻塞在条件变量上的线程将重新获得执行权并继续执行。

条件变量除了默认的成员函数外,主要提供了wait(阻塞等待)系列接口和notify(唤醒)系列的接口;下面我们先认识认识wait系列的接口:

 wait系列接口一共有三个,最主要的就是wait函数;

 wait函数有两个版本:

第一种wait:当调用这一个wait函数时,需要传入一个锁,通常这个锁需要交付给unique_lock来管理,当线程调用wait函数时,线程将会被阻塞在条件变量上同时,如果此时调用wait函数的线程身上持有锁那么这个线程将会在进入阻塞状态时将身上持有的锁释放掉!不能带着锁去阻塞,如果想让线程脱离阻塞状态,则必须被其他线程唤醒!!!

第二种wait:我们可以看到,第二种wait是带有模板的,当调用时,不仅仅要传入一个被unique_lock所管理的锁,还要传入一个可调用对象!!!

这个可调用对象如果返回的是false,就继续阻塞,如果是true,则从阻塞状态中脱离出来,立即参与到锁的竞争,同时,这个可调用对象将会被一直while循环判断阻塞状况,比较适合用于多生产多消费的场景下!(大概可以理解为是对第一种wait的封装)!

那为什么无论是第一种wait,还是第二种wait,都需要我们在使用时传入一个锁(mutex)呢,而且为什么都要使用unique_lock管理这个锁呢?

答:传锁是防止多个线程同时对共享资源进行操作!而用unique_lock管理锁是因为在线程进入阻塞之前,需要先将身上持有的锁先释放掉,而lock_guard不支持在构造函数加锁,析构函数解锁期间对锁做任何的操作,只有unique_lock支持!所以使用unique_lockj管理锁+条件变量的组合可以避免一些多线程并发的问题,比如死锁,竞态条件等,确保操作的正确性和同步性!

至于wait_for函数和wait_until函数用的比较少,我们大概了解一下即可,

wait_for:表示让线程在某一个时间段内(这个时间段需要我们自己设置)进行阻塞等待,如果超过这个时间段则线程被自动唤醒,也可以在等待的期间通过notify系列接口将其唤醒。

wait_until:表示让线程在某一个时间点(这个时间段需要我们自己设置)之前进行阻塞等待,如果超过这个时间点则线程被自动唤醒,也可以在等待的期间通过notify系列接口将其唤醒。

下面我们再认识一下notify系列接口:

notify_one:唤醒在条件变量阻塞队列中的任意一个线程!如果等待队列为空,则该函数不执行任何操作!

notify_all:唤醒在条件变量阻塞队列中的全部线程!如果等待队列为空,则该函数不执行任何操作!

(因为不止一个线程在条件变量上等待,所以条件变量让这些阻塞线程队列式的排队等待);

为了更好的体现和理解条件变量+互斥锁在多线程下的应用场景,这里就给出一道经典题目:

两个线程交替打印1~100,一个线程打印奇数,一个线程打印偶数,交替运行,该如何实现?

解题思路:线程互斥与同步

1:线程互斥:两个线程都需要打印数据,为了防止线程并发问题,我们需要一把锁,对共享资源加锁进行保护,确保线程打印时数据的安全性!

2:线程同步:需要两个线程交替打印,你打印1,我打印2,你打印3。。。。。;不能一个线程一直运行,我们需要一个线程在打印时,另一个线程停止(阻塞),这个线程打印完,它停下(阻塞),让另一个线程运行,形成交替,所以实现线程停下(阻塞)或者运行(唤醒)我们需要使用条件变量!!!

解题难点:

1:我们要让一个线程打印奇数,那么这个线程该怎么实现,使得它无论在什么情况下,都是它第一个运行,打印1呢?

     我们可不能说先创建的线程就先运行,因为有可能先创建的后运行,后创建的先运行!

2:我们如何控制交替打印,即不能让一个线程运行多次!!!

下面我们先解决第一个难点问题:

这样,即可实现无论在什么情况下,都可以保证线程t1先运行,但是我们看到,此时t2不是阻塞在锁上,就是阻塞在条件变量上,使得t1线程一直运行,我们该如何解决t1线程一直运行的问题呢?

此时,我们即可以让t1线程稳定的总是第一个运行,同时又通过if语句判断+条件变量(wait函数)让t1线程和t2线程交替打印,但是为什么最后的结果也只是两个线程各打印一次,那为什么不接着打印了呢?

答:因为当X=2时,t1线程阻塞在条件变量上,而t2线程执行完没有唤醒t1线程,而是继续运行,那么t2线程出了锁作用域,锁被释放,无人持有锁,

此时X=3了,t1线程还是在条件变量上阻塞着,所以锁还是被t2线程拿到,而t2线程进行if判断时,也调用了wait函数,t2线程也将被阻塞在条件变量上,锁被释放,那么就造成了锁无人持有,t1线程,t2线程都阻塞在条件变量上的局面!!!

所以究其根本,还是在于t2线程执行完没有唤醒t1线程,所以我们只需要在t2线程结束之前唤醒t1即可(形成互相唤醒的场景)!!!具体如下所示:

int main()
{
	int n = 100;
	int x = 1;
	mutex mtx;//定义一个全局的锁
	condition_variable cv;//定义全局的条件变量
	thread t1 = thread([&,n]()
		{
			while(x<n)
			{
				unique_lock<mutex> lock(mtx);//将锁用unique_lock管理
				if (x % 2 == 0)
				{
					cv.wait(lock);
				}
				cout << this_thread::get_id() << ":" << x << endl;
				++x;
				cv.notify_one();
				//cv.notify_all();//这里线程互相唤醒时也可以使用notify_all函数,但不是很推荐用!
			}
		});
	thread t2 = thread([&, n]()
		{
			while (x < n)
			{
				unique_lock<mutex> lock(mtx);//将锁用unique_lock管理
				if (x % 2 != 0)
				{
					cv.wait(lock);
				}
				cout << this_thread::get_id() << ":" << x << endl;
				++x;
				cv.notify_one();
				//cv.notify_all();
			}
		});
	t1.join();
	t2.join();
	return 0;
}

此时两个线程就可以一个打印奇数,一个打印偶数,交替的打印1~100!!!

但是这里while的循环判断条件x<n可能存在线程安全的问题,所以我们可以将while循环写死,然后在内部判断退出条件(下面将给出);同时wait函数还有另一个版本,带有模板的wait函数,下面我们用带模板的wait函数进行上述功能的实现:

int main()
{
	int n = 100;
	int x = 1;
	mutex mtx;//定义一个全局的锁
	condition_variable cv;//定义全局的条件变量
	thread t1 = thread([&,n]()
		{
			while(1)//写成死循环,在循环内部判断退出条件
			{
				unique_lock<mutex> lock(mtx);//将锁用unique_lock管理
				if (x >= n)
				{
					break;
				}
				//if (x % 2 == 0)
				//{
				//	cv.wait(lock);
				//}
				//如果可调用对象返回的是false,则阻塞,true则运行!
				cv.wait(lock, [&x]() {return x % 2 != 0; });
				cout << this_thread::get_id() << ":" << x << endl;
				++x;
				cv.notify_one();
				//cv.notify_all();//这里线程互相唤醒时也可以使用notify_all函数,但不是很推荐用!
			}
		});
	thread t2 = thread([&, n]()
		{
			while (1)
			{
				unique_lock<mutex> lock(mtx);//将锁用unique_lock管理
				if (x > n)
				{
					break;
				}
				//if (x % 2 != 0)
				//{
				//	cv.wait(lock);
				//}
				cv.wait(lock, [&x]() {return x % 2 == 0; });
				cout << this_thread::get_id() << ":" << x << endl;
				++x;
				cv.notify_one();
				//cv.notify_all();
			}
		});
	t1.join();
	t2.join();
	return 0;
}

此时,我们就用带有模板的wait函数完成了两个线程交替从1打印到100,并且全部都是线程安全的!!!

综上,这是我对C++11线程库的大致了解,我这里只是介绍了非常常用的接口与场景,至于其他的很多知识点还是边查文档边用吧,毕竟线程库内容还是比较多的,我们现在掌握一些最基本,最常用的即可!

二:异常

2.1:C语言中处理错误的方式

众所周知,C++是在C语言的基础上进行的扩展,所以在了解C++异常之前,我们得先看看C语言中是如何处理代码中存在的错误!

1:当出现的是比较严重的错误,如发生内存错误,除0错误时就可以通过断言(assert)直接终止掉程序,同时,它会告诉你出错的位置,方便我们查错,但是断言(assert)只在Debug版本下生效,在Release版本下无效!

2:当出现的是其他普通的错误,没有危害到程序的运行,此时我们就可以通过返回错误码的方式告知我们程序出现错误的原因,但是返回错误码有一个很大的缺陷:就是我们需要根据返回的错误码去找错误码对应的错误信息描述;这点在我们学习Linux系统编程时有着深刻的体验!!!

所以,实际中C语言基本上都是使用返回错误码的方式处理错误,部分情况下使用终止程序处理非常严重的错误!而正是由于c语言处理错误时,返回的信息有限,所以C++才衍生出了异常的概念!!!

2.2:异常的概念

异常是C++中一种处理错误的方式,当一个函数发现自己无法处理的错误时就可以抛出异常,让函数的直接或间接的调用者处理这个错误。下面给出使用异常时需要用的关键字:
throw: 当问题出现时,程序会抛出一个异常。这是通过使用 throw 关键字来完成的。
catch: 在您想要处理问题的地方,通过异常处理程序捕获异常,catch 关键字用于捕获异常,可以有多个catch进行捕获。
try: try 块中的代码标识将被激活的特定异常,它后面通常跟着一个或多个 catch 块。如果有一个块抛出一个异常,捕获异常的方法会使用 try 和 catch 关键字。try 块中放置可能抛出异常的代码,try 块中的代码被称为保护代码。使用 try/catch 语句的语法如下所示:

try
{
    // 保护的标识代码
}
catch( ExceptionName e1 )//ExceptionName:通过throw关键字抛出的异常的类型!
{
    // catch 块:可以在此作用域内,对捕捉到的异常进行处理!
}
catch( ExceptionName e2 )
{
    // catch 块
}
catch( ExceptionName eN )//可以通过throw关键字抛出n个异常,我们此时就需要n个catch进行捕捉!
{
    // catch 块
}

2.3:异常的具体用法

异常的抛出和匹配原则:

1:异常是通过抛出对象而引发的,该对象的类型决定了应该激活哪个catch的处理代码。下面我们给出一个示例,具体如下所示:

 2:被选中的处理代码是调用链中与该对象类型匹配离抛出异常位置最近的那一个

在解释这个规则之前,我们需要明白一个概念,什么叫调用链???

搞明白了什么是调用链,我们给出一个例子,方便我们了解第二条规则,具体如下所示:

这里是func函数和main函数都存在try/catch,且类型都匹配,要是类型不匹配或者没有try/catch时,又是什么样呢?

 此时,这里就说明抛异常时存在一个很大的隐患:即执行流乱跳!!!原本Division函数执行完需要接着执行func函数中的代码,但是一旦Division函数抛异常,那么它将跳到具有try/catch捕获异常的main函数中!!与我们学习的函数调用链相违背,提高了调试的难度!!!

3:抛出异常对象后,会生成一个异常对象的拷贝,因为抛出的异常对象可能是一个临时对象,所以会生成一个拷贝对象,这个拷贝的临时对象会在被catch以后销毁。(这里的处理类似于函数的传值返回)!


4.:catch(...)可以捕获任意类型的异常,但是我们也不知道异常错误是什么。下面我们给出一个示例,具体如下所示:

所以一般catch(...)通常放到捕获异常的最后面,捕获任意类型的异常(当然如果有和异常类型相匹配的try/catch,还是优先调用类型匹配的catch捕获,catch(...)只是底线保障!),防止因为一些异常没有被捕获,而导致程序崩溃退出!!!

5:实际中抛出和捕获的匹配原则有个例外,并不都是类型完全匹配,可以抛出的派生类对象,使用基类捕获(再加上多态),这个在实际中非常实用,实际使用中很多公司都会自定义自己的异常体系进行规范的异常管理,因为一个项目中如果大家随意抛异常,那么外层的调用者基本就没办法操作了;

所以实际中都会定义一套继承的规范体系。这样大家抛出的都是继承的派生类对象,捕获一个基类就可以了!下面给出一个异常的基类,里面只包括错误码和错误码描述,然后用它派生出不同的子类(比如单门派生一个SQL异常类。网络异常类,缓存异常类等,用来模拟不同的项目模块)!具体如下所示:

class Exception
{
public:
	Exception(const string& errmsg, int id)
		:_errmsg(errmsg)
		, _id(id)
	{}
	virtual string what() const//定义一个虚函数,让子类进行重写,以便形成多态!
	{
		return _errmsg;
	}
	int Get_id()
	{
		return _id;
	}
protected:
	string _errmsg;
	int _id;
};

class SQL_Exception : public Exception//SQL异常子类
{
public:
	SQL_Exception(const string& errmsg, int id, const string& sql)
		:Exception(errmsg,id)
		,_sql(sql)
	{
	}
	virtual string what() const//子类重写虚函数,
	{
		string str = "SQL_Exception:";
		 str += _errmsg;
		str += " SQL语句:";
		str += _sql;
		return str;
	}
private:
	string _sql;//SQL语句

};
class Cache_Exception : public Exception//缓存异常子类
{
public:
	Cache_Exception(const string& errmsg, int id)
		:Exception(errmsg, id)
	{
	}
	virtual string what() const//子类重写虚函数,
	{
		string str = "Cache_Exception:";
		 str += _errmsg;
		return str;
	}
};
class Network_Exception : public Exception//网络异常子类
{
public:
	Network_Exception(const string& errmsg, int id, const string& http)
		:Exception(errmsg, id)
		, _http(http)
	{
	}
	virtual string what() const//子类重写虚函数,
	{
		string str = "Network_Exception,HTTP:";
		str += _errmsg;
		str += _http;
		return str;
	}
private:
	string _http;//http协议
};

void SQLMgr()//模拟sql异常,通过生成随机数来throw抛异常
{
	srand(time(0));
	if (rand() % 7 == 0)
	{
		throw SQL_Exception("权限不足", 100, "select * from name = '张三'");
	}
}
void CacheMgr()//模拟缓存异常
{
	srand(time(0));
	if (rand() % 5 == 0)
	{
		throw Cache_Exception("权限不足", 100);
	}
	else if (rand() % 6 == 0)
	{
		throw Cache_Exception("数据不存在", 101);
	}
	SQLMgr();
}
void HttpServer()//模拟网络异常
{
	// ...
	srand(time(0));
	if (rand() % 3 == 0)
	{
		throw Network_Exception("请求资源不存在", 100, "get");
	}
	else if (rand() % 4 == 0)
	{
		throw Network_Exception("权限不足", 101, "post");
	}
	CacheMgr();
}
int main()
{
	while (1)
	{
		this_thread::sleep_for(chrono::seconds(1));
		try 
		{
			HttpServer();
		}
		catch (const Exception& e) // 这里捕获父类对象就可以
		{
			// 多态:子类对象通过父类的指针或者引用调用自己重写的虚函数!
			cout << e.what() << endl;//此时捕捉的是哪一个子类,调用的就是哪一个子类里面的what函数,打印异常信息!
		}
		catch (...)//捕捉任意类型的异常!防止某些异常没有被捕获,从而造成程序崩溃!!
		{
			cout << "未知异常" << endl;
		}
	}
	return 0;
}

此时我们通过继承和多态就形成了一个自定义的异常体系!那么外层的调用者就可以根据异常信息定位到确切的项目部分,甚至是具体问题!!!

其实,C++标准库当中的异常也是一个基础体系,其中exception就是各个异常类的基类,我们可以在程序中使用这些标准的异常,它们之间的继承关系如下:

下表是对上面继承体系中出现的每个异常的说明:   

2.4 异常的重新抛出

异常的重新抛出指的是在捕获异常后,再次将该异常抛出到上层调用者。这样做的主要目的是为了让更高层的代码能够处理这个异常或者继续将它向上层抛出。因为有可能单个的catch不能完全处理一个异常,在进行一些校正处理以后,希望再交给更外层的调用链函数来处理,catch则可以通过重新抛出将异常传递给更上层的函数进行处理!如果我们抛异常时,直接让最外层的try/catch捕获,可能会引发一些问题,具体如下所示:

double Division(int a, int b)
{
	// 当b == 0时抛出异常
	if (b == 0)
	{
		throw "Division by zero condition!";
	}
	return (double)a / (double)b;
}
void Func()
{
	int* array = new int[10];
	int len, time;
	cin >> len >> time;
	cout << Division(len, time) << endl;

	//此时如果 Division函数抛异常,那么new出来的空间将无法delete,可能会造成内存泄漏等问题!
	cout << "delete []" << array << endl;
	delete[] array;
}
int main()
{
	try
	{
		Func();
	}
	catch (const char* errmsg)
	{
		cout << errmsg << endl;
	}
	return 0;
}

 

 所以此时我们需要在func函数中将Division函数抛出的异常捕获,再由func函数重新抛出,但是在重新抛出之前,将new出来的空间delete释放掉即可!这样就防止了内存泄漏等重大问题!

但是,如果func不仅仅调用了Division函数,也调用了其他的函数,其他的函数内也抛了异常,那么我们是不是需要在func函数内在try/catch捕获呢?

答案是肯定的,那么我们总不能func函数内每多一个异常抛出,我们就在func函数内增加一个try/catch吧!!!所以此时,我们直接在func函数中使用catch(...),将所有的任意的异常捕获,在抛出之前,将该做的事情做完(释放new出来的空间等),然后重新抛出!!!具体如下所示:

double Division(int a, int b)
{
	// 当b == 0时抛出异常
	if (b == 0)
	{
		throw "Division by zero condition!";
	}
	return (double)a / (double)b;
}
void Division1()//此时这个函数也可能抛异常!
{
	if (rand()%2==0)
	{
		throw 1;
	}
	return;
}
void Func()
{
	// 这里可以看到如果发生除0错误抛出异常,另外下面的array没有得到释放。
	// 所以这里捕获异常后并不处理异常,异常还是交给外面处理,这里捕获了再 重新抛出去。
	int* array = new int[10];
	try 
	{
		int len, time;
		cin >> len >> time;
		cout << Division(len, time) << endl;
		Division1();//func函数又调用一个可能会发生异常的函数
	}
	catch (...)//此时我们无论在func函数中捕获都是任意的异常,都重新抛出去,集中到main函数处理!
	{
		cout << "delete []" << array << endl;
		delete[] array;
		throw;
	}
}
int main()
{
	try
	{
		Func();
	}
	catch (const char* errmsg)
	{
		cout << errmsg << endl;
	}
	catch (...)
	{
		cout << "未知异常" << endl;
	}
	return 0;
}

 这样不让最外层的函数直接捕获异常,而是经由调用链中的函数将异常重新抛出,不仅保证了程序的安全性,同时还将所有的异常都放到最外层的main函数进行处理,提高代码可靠性和健壮性!

2.5:异常安全与规范

异常安全:
构造函数完成对象的构造和初始化,最好不要在构造函数中抛出异常,否则可能导致对象不完整或没有完全初始化

析构函数主要完成资源的清理,最好不要在析构函数内抛出异常,否则可能导致资源泄漏(内存泄漏、句柄未关闭等)
C++中异常经常会导致资源泄漏的问题,比如在new和delete中抛出了异常,导致内存泄漏,在lock和unlock之间抛出了异常导致死锁,C++经常使用RAII来解决以上问题!


异常规范:
1. 异常规格说明的目的是为了让函数使用者知道该函数可能抛出的异常有哪些。 可以在函数的后面接throw(类型),列出这个函数可能抛掷的所有异常类型。
2. 函数的后面接throw(),表示函数不抛异常。
3. 若无异常接口声明,则此函数可以抛掷任何类型的异常

// 这里表示这个函数会抛出A/B/C/D中的某种类型的异常
void fun() throw(A,B,C,D);

// 这里表示这个函数只会抛出bad_alloc的异常
void* operator new (std::size_t size) throw (std::bad_alloc);

// 函数后面+throw()就表示这个函数不会抛出异常
void* operator delete (std::size_t size, void* ptr) throw();

// C++11 中新增的noexcept,表示不会抛异常
thread() noexcept;
thread (thread&& x) noexcept;

2.6:异常的优缺点

C++异常的优点:

1.可读性和可维护性:使用异常处理可以使代码更清晰,易于理解和维护。抛出异常可以使错误处理逻辑与正常代码分离,使代码更易于阅读。

2.错误处理:异常处理提供了一种简单有效的方法来管理程序中的错误。当程序遇到错误时,可以抛出异常并捕获它们,以便正确地处理它们。

3.可靠性和稳定性:使用异常处理可以使程序更稳定,因为可以更好地管理错误。异常处理可以使程序更可靠并减少错误的可能性,因为异常处理可以捕获和处理错误。

4.异常安全:使用异常处理可以使程序更加异常安全。异常安全是指程序在遇到异常时不会泄漏资源并保证数据的完整性。使用异常处理可以使程序更容易实现异常安全。

5.多个函数调用:使用异常处理可以在多个函数调用之间传递错误信息,使代码更模块化。当一个函数抛出异常时,可以使用异常处理机制将其传递到调用该函数的上一层函数中。

6.标准化:C++的异常处理是标准化的,并且在大多数系统和编译器上都可用。它是一种被广泛使用的错误处理机制;

C++异常的缺点:

1.性能损失:异常处理需要消耗额外的资源,包括内存和处理时间。在程序遇到异常时,为了适当地处理它们,程序必须执行额外的代码,导致效率下降。

2.代码复杂性:异常处理引入了额外的控制流,可能使代码更难理解和维护。此外,异常处理可能导致代码中的混乱,使得代码逻辑变得复杂。

3.未处理的异常:异常可能会被忽略或未正确处理。如果异常被忽略,程序可能会继续执行,导致错误的结果或系统崩溃。如果异常未正确处理,程序可能会崩溃并可能导致数据损坏。

4.不确定性:异常处理机制可能导致不确定性,即程序可能会产生不同的结果,因为异常可能在不同的时间发生或以不同的顺序发生。

5.可预测性:使用异常处理使得难以预测代码行为,因为异常可能会从任何地方抛出,使得代码执行流程变得不稳定。此外,异常的行为可能因平台或编译器而异。
总结:异常总体而言,利大于弊,所以在项目,工程实践中我们还是积极使用异常的!!!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/222097.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

Web开发-问题-前后端交互数据不一致

0x01 问题描述 所用的技术&#xff1a;VueSpring Boot后端传给前端数据&#xff1a; [Student(studentId1, personorg.fatmansoft.teach.models.Person4abe6020, major软件工程, className一班, grade一年级), Student(studentId2, personorg.fatmansoft.teach.models.Person…

如何利用CentOS7+docker+jenkins+gitee部署springboot+vue前后端项目(保姆教程)

博主介绍&#xff1a;Java领域优质创作者,博客之星城市赛道TOP20、专注于前端流行技术框架、Java后端技术领域、项目实战运维以及GIS地理信息领域。 &#x1f345;文末获取源码下载地址&#x1f345; &#x1f447;&#x1f3fb; 精彩专栏推荐订阅&#x1f447;&#x1f3fb;…

纯CSS实现计时器

跟b站 小k师兄学习一手CSS的计时器 1. 基本样式 注意&#xff0c;这里开始按钮使用伪类进行标签名字的设定&#xff0c;因为开始按钮点击以后有一个暂停的功能&#xff0c;就先不写死了。 <!DOCTYPE html> <html lang"en"><head><meta charset&…

2024年CSC国际区域问题研究及外语高层次人才培养项目介绍

国家留学基金委&#xff08;CSC&#xff09;公布了2024年国际区域问题研究及外语高层次人才培养项目&#xff0c;申报时间均为3月中下旬。为帮助关注者了解项目申报情况&#xff0c;知识人网小编特整理本文。 近日&#xff0c;国家留学基金委&#xff08;CSC&#xff09;公布了…

在Linux上优化HTTP服务器的性能

在Linux上优化HTTP服务器的性能是一个涉及多个方面的任务&#xff0c;包括服务器硬件、网络设置、软件配置和内容优化。以下是一些关键的优化建议&#xff1a; 选择合适的HTTP服务器软件 Linux上有多种HTTP服务器软件&#xff0c;如Apache、Nginx、Lighttpd等。选择适合您需求…

stm32项目(11)——基于stm32的俄罗斯方块游戏机

1.功能设计 使用stm32f103zet6平台&#xff0c;以及一块LCD屏幕&#xff0c;实现了一个俄罗斯方块游戏机。可以用按键调整方块的位置、还可以控制方块下降的速度&#xff01; 2.视频演示 俄罗斯方块 3.俄罗斯方块发展史 俄罗斯方块是一种经典的拼图游戏&#xff0c;由苏联俄罗…

VMware虚拟机搭建+云平台购买搭建(阿里云+UCloud)【设置主机名以及主机名映射、配置免密登录、配置JDK】

本地虚拟机的搭建 一、准备网段 在VMware的虚拟网络编辑器中将VMnet8虚拟网卡的 网段设置为&#xff1a;192.168.88.0网关设置为&#xff1a;192.168.88.2 二、下载CentOS操作系统文件&#xff0c;并安装 三、克隆多台虚拟机 依照同样的方法&#xff0c;克隆出node2…

HashMap相关专题

前置知识&#xff1a;异或运算 异或运算介绍 异或有什么神奇之处&#xff08;应用&#xff09;&#xff1f; &#xff08;1&#xff09;快速比较两个值 &#xff08;2&#xff09;我们可以使用异或来使某些特定的位翻转&#xff0c;因为不管是0或者是1与1做异或将得到原值的相…

3.4 路由器的DHCP配置

实验3.4 路由器的DHCP配置 一、任务描述二、任务分析三、具体要求四、实验拓扑五、任务实施&#xff08;一&#xff09;配置基于接口地址池的DHCP1.交换机的基本配置2.路由器的基本配置3.开启路由器的DHCP服务器功能4.配置路由器接口的DHCP功能5.设置计算机使用DHCP方式获取IP地…

【漏洞复现】狮子鱼任意文件上传漏洞

漏洞描述 狮子鱼CMS(Content Management System)是一种网站管理系统,旨在帮助用户更轻松地创建和管理网站。它具有用户友好的界面和丰富的功能,包括页面管理、博客、新闻、产品展示等。 狮子鱼CMS使用简单直观的管理界面,使得网站所有者可以方便地进行内容的发布、管理和…

手机怎么录屏?实用技巧,轻松录制!

手机录屏功能在现代通信和创作中扮演着重要的角色。无论是分享游戏过程、演示手机操作&#xff0c;还是创作教程视频&#xff0c;手机录屏成为了用户不可或缺的工具。本文将深入研究手机怎么录屏的三种方法&#xff0c;通过详细的步骤介绍&#xff0c;帮助用户轻松掌握手机录屏…

基于BEV+Transformer的地面要素感知+建模技术在高德的应用

导读 本文将主要介绍BEVTransformer端到端感知与建模技术在高德各项业务中的应用&#xff0c;如高精地图中地面要素&#xff08;包含线要素和地面标识&#xff09;自动化上的具体方案及其演化过程。该方案使用BEVTransformer技术来实现采集车上不同传感器&#xff08;包含激光和…

Linux环境搭建SVN服务器并结合内网穿透实现远程访问

&#x1f308;个人主页&#xff1a;聆风吟 &#x1f525;系列专栏&#xff1a;网络奇遇记、Cpolar杂谈 &#x1f516;少年有梦不应止于心动&#xff0c;更要付诸行动。 文章目录 &#x1f4cb;前言一. Ubuntu安装SVN服务二. 修改配置文件2.1 修改svnserve.conf文件2.2 修改pass…

​HTML代码混淆技术:原理、应用和实现方法详解

​HTML代码混淆技术&#xff1a;原理、应用和实现方法详解 HTML代码混淆是一种常用的反爬虫技术&#xff0c;它可以有效地防止爬虫对网站数据的抓取。本文将详细介绍HTML代码混淆技术的原理、应用以及实现方法&#xff0c;帮助大家更好地了解和运用这一技术。 一、HTML代码混淆…

2024年终工作总结汇报,这样来写更科学

时光荏苒&#xff0c;2024年已经悄然而至&#xff0c;伴随而来的是一年一度的终年工作总结&#xff0c;以回首过去的一年的成果与不足。然而&#xff0c;怎样写一份既真实又有说服力的总结汇报成了摆在很多人面前的一道难题。 在为年终总结苦恼时&#xff0c;我突然想到了平时…

如何用好金鸣表格文字识别电脑客户端的“图片编辑”功能?

想要让文字识别效果更上一层楼&#xff0c;图片编辑处理少不了&#xff0c;今天就来教大家如何运用金鸣表格文字识别电脑客户端编辑器提升图片质量。 首先&#xff0c;点击顶部导航菜单中的“软件下载”&#xff0c;下载安装金鸣表格文字识别电脑客户端&#xff0c;并将图片添加…

KubeKey 升级 KubeSphere 和 Kubernetes 补丁版本实战指南

作者&#xff1a;运维有术 前言 知识点 定级&#xff1a;入门级KubeKey 如何升级 KubeSphere 补丁版本KubeKey 如何升级 Kubernetes 补丁版本KubeSphere 和 Kubernetes 升级准备及验证KubeKey 升级 KubeSphere 和 Kubernetes 的常见问题 实战服务器配置 (架构 1:1 复刻小规…

从0到1构建智能分布式大数据爬虫系统

文章目录 1. 写在前面2. 数据获取挑战3. 基础架构4. 爬取管理5. 数据采集6. 增量与去重设计 【作者主页】&#xff1a;吴秋霖 【作者介绍】&#xff1a;Python领域优质创作者、阿里云博客专家、华为云享专家。长期致力于Python与爬虫领域研究与开发工作&#xff01; 【作者推荐…

更改 Mac 所使用网络服务的顺序

如果以多种不同的方式&#xff08;例如使用 Wi-Fi 或以太网&#xff09;接入互联网或网络&#xff0c;你可以更改连接时电脑所尝试的网络连接顺序。 如果有多个活跃的连接&#xff0c;电脑会首先尝试列表顶部的连接&#xff0c;然后按降序尝试其他连接。 你不能更改虚拟专用网…

剪刀石头布游戏

csdn问答社区的一道题目&#xff0c;题目描述都像一篇论文了&#xff0c;界面设置不敢恭维&#xff0c;不过也算是可练手工程。 (笔记模板由python脚本于2023年12月05日 22:15:03创建&#xff0c;本篇笔记适合熟悉Python字典、列表、字符串的coder翻阅) 【学习的细节是欢悦的历…
最新文章