学习
c++内存管理RAII
RAII(Resource Acquisition Is Initialization)资源获取即初始化。代表使用局部对象来管理资源的技术。
这里的资源主要指操作系统中有限的东西,例如内存、网络套接字等,局部对象指的存储在栈的对象,它的生命周期是由操作系统来管理的,无需人工介入。
资源的使用一般经历3个步骤:获取资源、使用资源、销毁资源。但是销毁资源经常忘记,需要自动销毁。RAII给出的方案是利用C++语言局部对象自动销毁的特性来控制资源的生命周期,有效防止资源泄露和程序异常。
智能指针是RAII机制的一种典型应用。通过封装原生指针,智能指针能自动管理内存的生命周期,从而避免野指针。
c++构造函数
- C++的构造函数不是线程安全的
- 构造函数是对类的数据成员进行初始化和分配内存,一个C++的空类的默认的成员函数有:默认构造函数和拷贝构造函数、析构函数、赋值函数(赋值运算符)、取值函数。构造函数可以被重载,可以多个可以带参数。析构函数只有一个,不能被重载,不能带参数。
C++拷贝构造函数
拷贝构造函数和赋值函数容易混淆。拷贝构造函数是在对象被创建时调用,而赋值函数只能被已经存在了的对象调用。
string c = a;//调用了拷贝构造函数,最好写成c(a)
c = b; //调用了赋值函数
当没有重载拷贝构造函数时,通过默认拷贝构造函数来创建一个对象
A a;
A b(a); // 拷贝构造
A b = a; // 拷贝构造
C++中三种对象需要复制,此时拷贝构造函数会被调用:
- 一个对象以值传递的方式传入函数体
- 一个对象以值传递的方式从函数返回
- 一个对象需要通过另外一个对象进行初始化
系统提供的默认拷贝构造函数是内存拷贝即浅拷贝。如果对象中用到了需要手动释放的对象,则会出现问题,此时需要手动重载拷贝构造函数,实现深拷贝。
- 浅拷贝:如果复制的对象中引用了一个外部内容(例如分配在堆上的数据),那么在复制这个对象的时候,让新旧两个对象指向同一个外部内容。(指针虽然复制了,但是所指向的空间内容没有复制,而是由两个对象共用)
- 深拷贝:复制这个对象的时候为新对象制作了外部对象的独立复制。
拷贝构造函数重载如下:
A(const A &other) : m_i(other.m_i) {}
C++赋值函数
赋值运算符的重载声明如下:
A& operator = (const A& other)
拷贝构造函数和赋值函数区别:
- 拷贝构造函数是一个对象初始化一块内存区域,这块内存就是新对象的内存区,而赋值函数是对于一个已经被初始化的对象来进行赋值操作。
- 一般来说,在数据成员包含指针对象的时候,需要考虑两种不同的处理需求,一种是复制指针对象,另一种是引用指针对象。拷贝构造函数大多数情况下是复制,而赋值函数是引用对象。
- 拷贝构造函数首先是一个构造函数,它调用的时候是通过参数的对象初始化产生一个对象。赋值函数则是把一个新的对象赋值给一个原有的对象,所以如果原来的对象中有内存分配,要先把内存释放掉,而且还要检查一下两个对象是否为同一个对象,如果是,不做任何操作,直接返回。
C++进程
进程控制块PCB:本质上是一个结构体,包含了进程相关的一系列信息:
- 进程id:每一个进程的唯一标识,类型pid_t,其实是int
- 进程的状态:开始、就绪、阻塞、运行、终止
- 进程切换所需要保存的和恢复的现场信息:其实就是寄存器里的值
- 描述虚拟地址空间的信息
- 描述当前控制终端的信息
- 当前工作目录
- umask掩码:保护文件权限
- 文件描述符表
- 信号相关信息
- 会话和进程组:守护进程会用到
- 用户ID和组ID
- 进程的资源上线
进程控制
fork函数:创建一个子进程,失败返回-1,成功父进程返回子进程的pid,子进程返回0
- fork之后的父子进程关系,实际上是子进程把父进程的内容拷贝了一份。遵从“读时共享,写时复制”,意思是当父子进程有写的操作时,就会单独给子进程映射一块物理内存,防止父子进程操作同一个数据而导致数据错误。调用fork函数的进程将根据当前进程的内容复制得到一个子进程,那么当前进程就会成为新进程的父进程,新进程的内容与父进程的内容几乎一致。只有当父进程或子进程需要修改数据时,才将父进程的数据在内存当中拷贝一份,然后再进行修改。
- 需要fork进程的场景:
- 在服务器程序中,一个请求到来后,为了避免服务器阻塞,fork出一个子进程处理请求,父进程仍然继续等待请求的到来,但是开销会稍大
- 一个进程要执行一个不同的程序,例如shell端执行一条命令,实际上是bash调用fork之后在执行exec函数
- 一个子进程同一时刻最多只有一个父进程。fork之后,父进程和子进程谁先执行完全由调度器决定。
- fork之后父子进程的异同:
- 相同:全局变量、data、bss、.txt、堆栈、环境变量、用户ID、当前工作目录......
- 不同:进程ID、fork的返回值、父进程ID、进程运行的时间、定时器、子进程不继承父进程设置的文件锁......
- 个人理解:fork之后,产生的两个进程都会执行fork后面的代码
- fork调用失败的原因可能是
- 系统进程太多,内存空间不足
- 实际用户的进程数超过了限制
进程退出
- return、exit和_exit之间的区别:只有在main函数中的return才能起到退出进程的作用,子函数中return不能退出进程
- 使用exit函数退出进程前,exit函数会执行用户定义的清理函数、冲刷缓冲,关闭流等操作,然后再终止进程,而_exit函数直接终止进程,不会做任何收尾工作。
进程等待的必要性
- 子进程退出,父进程如果不读取子进程的退出信息,子进程就会变成僵尸进程,进而造成内存泄漏
- 进程一旦变成僵尸进程,那么就算是kill -9命令也无法将其杀死,因为谁也无法杀死一个已经死去的进程
- 对于一个进程来说,最关心自己的就是其父亲进程,因为父进程需要知道自己派给子进程的任务完成的如何
- 父进程需要通过进程等待的方式,回收子进程资源,获取子进程的退出信息
线程同步的4种方式
在并发的情况下,指令执行的先后顺序由内核决定。同一个线程的内部,指令按照先后顺序执行,但不同线程之间的指令很难说清楚哪一个会先执行。如果运行的结果依赖于不同线程执行的先后的话,那么就会造成竞争条件,在这样的状况下,计算机的结果很难预知,所以应该尽量避免竞争条件的形成。
C++线程创建
c++线程创建用到#include <thread>
thread t1(run, 1); //第一个参数为函数,第二个参数为函数参数,如果函数没有参数,thread对象便没有第二个参数,同理若函数有两个参数,thread变量声明中便有三个参数。
多线程同步
对于多线程的程序来说,同步指的是在一定的时间内只允许某一个线程访问某个资源,具体可以使用一下四种方式实现:
- 互斥锁mutex
- 最常见的线程同步方式,它是一种特殊的变量,它有 lock 和 unlock 两种状态,一旦获取,就会上锁,且只能由该线程解锁,期间,其他线程无法获取。在使用同一个资源前加锁,使用后解锁,即可实现线程同步,需要注意的是,如果加锁后不解锁,会造成死锁
- 优点:使用简单
- 缺点:重复锁定和解锁,每次都会检查共享数据结构,浪费时间和资源;频繁查询的效率非常低
- #include <mutex> 声明mutex变量和lock_guard变量 =>mutex m;lock_guard<mutex> l(m);lock_guard优势是实现简单、使用方便,适用于大多数场景,但存在的问题是使用场景过于简单,无法处理一些精细操作。此时便需要使用unique_lock。unique_lock<mutex> l(m);
- 条件变量condition
- 当线程在等待某些满足条件时使线程进入睡眠状态,一旦条件满足,就唤醒,这样不会占用宝贵的互斥对象锁,实现高效。条件变量允许线程阻塞并等待另一个线程发送信号,一般和互斥锁一起使用。条件变量被用来阻塞一个线程,当条件不满足时线程会解开互斥锁,并等待条件发生变化。一旦其他线程改变了条件变量,将通知相应的阻塞线程,这些线程重新锁定互斥锁,然后执行后续代码,最后在解开互斥锁。
- 读写锁reader-writer lock
- 也称为共享-独占锁,一般用在读和写的次数有很大不同的场合。即对某些资源的访问会出现两种情况,一种是访问的排他性,需要独占,称之为写操作;还有就是访问可以共享,称之为读操作。
- 最适用于对数据结构的读操作次数多余写操作次数的场合
- 读写锁有以下几种状态:
- 写锁定的状态,在解锁前,所有试图加锁的线程都会阻塞
- 读锁定的状态,所有试图以读模式加锁的线程都可以得到访问权,但是以写模式加锁的线程会阻塞
- 读写锁处于读模式的锁(未加锁)状态时,有另外的线程试图以写模式加锁,则读写锁会阻塞读模式的加锁请求,这样避免了读模式锁长期占用导致写模式锁长期阻塞的情况
- 处理这种问题一般有两种常见的策略:
- 强读者同步:总是给读者更高的优先权,只要没有写操作,读者就可以获取访问权,比如图书馆查询系统
- 强写者同步:读者只能等到写者结束之后才能执行,比如航班订票系统查看最新的信息记录
- 信号量semphore
- 信号量和互斥锁的区别在于:互斥锁只允许一个线程进入临界区,信号量允许多个线程同时进入临界区。即互斥锁使用对同一个资源的互斥的方式达到线程同步的目的,信号量可以同步多个资源以达到线程同步(每个进程中访问临界资源的那段程序称为临界区(临界资源是一次仅允许一个进程使用的共享资源)。每次只准许一个进程进入临界区,进入后不允许其他进程进入)