【C++11】多线程

      • 多线程
        • 创建线程
        • thread提供的成员函数
        • 获取线程id的方式
        • 线程函数参数的问题
        • 线程join场景和detach
      • 互斥量库(mutex)
        • mutex
        • recursive_mutex
        • lock_guard 和 unique_lock
      • 原子性操作库(atomic)
      • 条件变量库(condition_varuable)
      • 综合案例(实现两个线程交替打印1-100)

多线程

在C++11之前,涉及到多线程问题,都是和平台相关的,比如Windows和Linux下各有自己的接口,这使得代码的可移植性比较差。C++11中最重要的特性就是对线程进行了支持,使得C++在并行编程时不需要依赖第三方库,而且在原子操作中还引入了原子类的概念

创建线程

在这里插入图片描述

  1. 调用无参的构造函数创建

thread提供了无参的构造函数,调用无参的构造函数创建出来的线程对象没有关联任何的线程函数对象,也没有启动任何线程。

thread t1;
#include <iostream>
#include <thread>
using namespace std;void func(int n)
{cout << n << endl;
}int main()
{thread t1;t1 = thread(func, 10);t1.join();return 0;
}

我们的thread是提供了移动赋值函数的,所以,当后序需要让该线程关联线程函数的时候,我们可以定义一个匿名的线程,然后调用移动赋值传给他
在这里插入图片描述
thread类是防拷贝的,不允许拷贝构造和拷贝赋值,但是可以移动构造和移动赋值,可以将一个线程对象关联线程的状态转移给其他线程对象,并且转移期间不影响线程的执行。

  1. 调用带参的构造函数

thread的带参构造函数的定义如下:

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

参数说明:

  • fn:可调用对象,比如:仿函数,指针,lambda表达式,被包装器包装后的可调用对象等。
  • args:进行对fn进行传值。

调用带参的构造函数创建线程对象,能够将线程对象与线程函数fn进行关联。比如:

void Func(int n, int num)
{for (int i = 0; i < n; i++){cout <<num<<":" << i << endl;}cout << endl;
}int main()
{thread t2(Func, 10,666);t2.join();return 0;
}

在这里插入图片描述

结合之前的lambda函数,这么我们可以很明显的看到lambda的作用

int main()
{//thread t2(Func, 10,666);thread t2([](int n = 10, int num = 666){for (int i = 0; i < n; i++){cout << num << ":" << i << endl;}cout << endl;});t2.join();return 0;
}

其输出结果和上图是一样的,注意,这么线程的形参只有一个仿函数lambda

使用一个容器来保存线程

#include <iostream>
#include <thread>
#include <vector>
using namespace std;
int main()
{size_t m;//线程个数cin >> m;vector<thread> vthread(m);//直接初始化长度为10for (int i = 0; i < m; ++i){vthread[i] = thread([i](){cout << "我是第" << i << "个线程" << endl;});}for (auto& e : vthread){e.join();}return 0;
}

在这里插入图片描述

thread提供的成员函数

在我们多线程中,常用的成员函数如下:

  • join:对该线程进行等待,在等待的线程返回之前,调用join将会将线程进行阻塞,我们主要阻塞的是主线程。
  • joinable:判断该线程是否已经执行完毕,如果是则返回true,否则返回false。
  • 在这里插入图片描述
  • detach:将该线程进行分离主线程,被分离后,不在需要创建线程的主线程调用join进行对其等待
  • get_id:获取该线程的id

此外,joinable函数还可以用于判定线程是否是有效的,如果是以下任意情况,则线程无效:

  • 采用无参构造函数构造的线程对象。(该线程对象没有关联任何线程)
  • 线程对象的状态已经转移给其他线程对象。(已经将线程交给其他线程对象管理)
  • 线程已经调用join或detach结束。(线程已经结束)
获取线程id的方式

其实调用线程id的方法有两种,实际情况我们看下边的代码:

#include <iostream>
#include <thread>
#include <vector>
using namespace std;
int main()
{size_t m;//线程个数cin >> m;vector<thread> vthread(m);//直接初始化长度为10for (int i = 0; i < m; ++i){vthread[i] = thread([i](){printf("我是第%d个线程\n", i);cout << this_thread::get_id() << endl;});}for (auto& e : vthread){cout << e.get_id() << endl;}for (auto& e : vthread){e.join();}return 0;
}

在这里插入图片描述

  • 两者调用ID的环境是不同的
  • 从代码中我们可以看到get_id是需要线程对象来调用的
  • 但是this_thread::get_id我们通过多线程提供的特殊窗口可以不通过线程对象就可以直接调用

this_thread命名空间中还提供了以下三个函数:

  • yield:当前线程“放弃”执行,让操作系统调度另一线程继续执行
  • sleep_until :让当前线程休眠到一个具体时间点
  • sleep_for:让当前线程休眠一个时间段
线程函数参数的问题

线程函数的参数是以值拷贝的方式拷贝到线程栈空间中的,就算线程函数的参数为引用类型,在线程函数中修改后也不会影响到外部实参,因为其实际引用的是线程栈中的拷贝,而不是外部实参。比如:

void add(int& num)
{num++;
}
int main()
{int num = 0;thread t(add, num);t.join();cout << num << endl; //0return 0;
}

解决其办法有三种:

  • 方式一:借助std::ref函数

  • 在这里插入图片描述

  • 方式二:地址的拷贝

  • 在这里插入图片描述

  • 方式三:借助lambda表达式

  • 在这里插入图片描述

线程join场景和detach

当启动线程后,如果不使用join进行阻塞等待的话程序就会直接报错,因为此时存在内存泄漏问题。

thread库给我们提供了一共两种回收线程的方法

join方式

使用join进行线程回收时,一个线程只能回收一个,如果进行多次回收就会直接报错

但是如果你对一个线程回收后,又对此线程再一次的进行了移动赋值,那么此时还可以再一次的对线程进行二次join,如下代码案例:

void func(int n = 20)
{cout << n << endl;
}int main()
{thread t(func, 20);t.join();t = thread([](int num = 10) {cout << num << endl; });t.join();
}

在这里插入图片描述
需要注意的是,如果线程运行起来后,线程的内部发生了异常,那么我们锁设置的阻塞就起不到任何作用了,

detach方式

主线程创建新线程后,也可以调用detach进行将线程和主线程分离,分离后,新线程会到后台运行,其所有权和控制权将会交给C++运行库,此时,c++运行库会保证当线程退出时,其相关资源能够被正确回收

#include <mutex>
#include <iostream>
#include <thread>
int x = 0;
mutex mtx;
void threadFunc(int n)
{mtx.lock();for (int i = 0; i < 100000; i++){x++;}std::cout << "Hello from detached thread:" << n << std::endl;mtx.unlock();
}int main()
{std::thread t1(threadFunc,11111);std::thread t2(threadFunc,22222);t1.detach();  // 将线程设置为可分离的t2.detach();  // 将线程设置为可分离的// 主线程继续执行其他任务std::cout << "Main thread continues..." << std::endl;// 不要忘记在主线程结束前等待一段时间,以免分离的线程还未执行完std::this_thread::sleep_for(std::chrono::seconds(1));cout << x << endl;return 0;
}

在这里插入图片描述

互斥量库(mutex)

在我们的c++11中,我们一共提供了四种互斥锁形式

mutex

1. std::mutex
mutex锁是C++11提供的最基本的互斥量,mutex对象之间不可以进行拷贝,也不能进行移动。

mutex中常用的成员函数如下:

  • lock:对互斥量进行加锁
  • unlock:对互斥量进行解锁,释放互斥量所有权
  • try_lock:尝试对互斥量进行加锁。

线程函数调用lock时,可能会发生三种情况:

  • 如果互斥量当前没有被其他线程锁住,则调用线程将该互斥量进行加锁,知道调用 unlock后,才对其进行解锁。
  • 如果进行加锁时,发现已经被其他线程已经加过锁了,此时会进入阻塞状态。
  • 如果该互斥量被当前调用线程锁住,则会产生死锁

线程函数调用try_lock时,可能会发生三种情况:

  • 如果互斥量当前没有被其他线程锁住,则调用线程将该互斥量进行加锁,知道调用 unlock后,才对其进行解锁。
  • 如果进行加锁时,发现已经被其他线程已经加过锁了,此时会返回false,并不会对其进行阻塞。
  • 如果该互斥量被当前调用线程锁住,则会产生死锁

在没有对临界资源加锁的时候,由于是多个进程同时进行,这时,不能同步的,正确的完成我们的任务,此时我们就需要给临界资源进行加锁

在这里插入图片描述

正确做法

#include <mutex>
#include <iostream>
#include <thread>
int x = 0;
mutex mtx;
void threadFunc(int n)
{mtx.lock();for (int i = 0; i < 100000; i++){x++;}std::cout << "Hello from detached thread:" << n << std::endl;mtx.unlock();
}int main()
{std::thread t1(threadFunc,11111);std::thread t2(threadFunc,22222);t1.detach();  // 将线程设置为可分离的t2.detach();  // 将线程设置为可分离的// 主线程继续执行其他任务std::cout << "Main thread continues..." << std::endl;// 不要忘记在主线程结束前等待一段时间,以免分离的线程还未执行完std::this_thread::sleep_for(std::chrono::seconds(1));cout << x << endl;return 0;
}

在这里插入图片描述

recursive_mutex

2. std::recursive_mutex
recursive_mutex叫做递归互斥锁,该锁专门用于递归函数中的加锁操作。

  • 有一些线程避免不了它是递归函数,但是如果对递归里的临界资源进行加锁时,可能会持续申请自己还还未释放的锁,进而导致死锁问题。
  • 而recursive_mutex允许同一个线程对互斥量进行多次上锁(即递归上锁),来获得互斥量对象的多层所有权,但是释放互斥量时需要调用与该锁层次深度相同次数的unlock。
int x = 0;
recursive_mutex mtx;void Func(int n)
{if (n == 0)return;//递归锁的原理就是当调用自己本身时,发现给自己上锁的还是自己,这时会自动解锁,通过此种方法来进行重复加锁解锁mtx.lock();++x;Func(n - 1);mtx.unlock();
}int main()
{thread t1(Func, 1000);thread t2(Func, 2000);t1.join();t2.join();cout << x << endl;return 0;
}

在这里插入图片描述
后两种可以查看收藏

lock_guard 和 unique_lock

为什么要使用lock_guard 和 unique_lock呢?

在我们平时使用锁中,如果锁的范围比较大,那么极度有可能在中途时忘记解锁,此后申请这个锁的线程就会被阻塞住,也就造成了死锁的问题,例如:

mutex mtx;
void func()
{mtx.lock();//...FILE* fout = fopen("data.txt", "r");if (fout == nullptr){//...return; //中途返回(未解锁)}//...mtx.unlock();
}
int main()
{func();return 0;
}

因此使用互斥锁时如果控制不好就会造成死锁,最常见的就是此处在锁中间代码返回,此外还有一个比较常见的情况就是在锁的范围内抛异常,也很容易导致死锁问题。

因此C++11采用RAII的方式对锁进行了封装,于是就出现了lock_guard和unique_lock。

为此c++11推出了解决方案,采用RAII的方式对锁进行了封装,于是就出现了lock_guard和unique_lock。

lock_guard

lock_guard是C++11中的一个模板类,其定义如下:

template <class Mutex>
class lock_guard;

通过这种构造对象时加锁,析构对象时自动解锁的方式就有效的避免了死锁问题。比如:

mutex mtx;
void func()
{lock_guard<mutex> lg(mtx); //调用构造函数加锁//...FILE* fout = fopen("data.txt", "r");if (fout == nullptr){//...return; //调用析构函数解锁}//...
} //调用析构函数解锁
int main()
{func();return 0;
}

模拟实现lock_guard

模拟实现lock_guard类的步骤如下:

  • lock_guard类中包含一个锁成员变量(引用类型),这个锁就是每个lock_guard对象管理的互斥锁。
  • 调用lock_guard的构造函数时需要传入一个被管理互斥锁,用该互斥锁来初始化锁成员变量后,调用互斥锁的lock函数进行加锁。
  • lock_guard的析构函数中调用互斥锁的unlock进行解锁。
  • 需要删除lock_guard类的拷贝构造和拷贝赋值,因为lock_guard类中的锁成员变量本身也是不支持拷贝的
class Lock_guard
{
public:Lock_guard(mutex& mtx):_mtx(mtx){_mtx.lock(); //加锁}~Lock_guard(){_mtx.unlock();//解锁}Lock_guard(const Lock_guard&) = delete;Lock_guard& operator=(const Lock_guard&) = delete;
private:mutex& _mtx;
};mutex mtx;
int x;
int main()
{thread t1([](){Lock_guard lg(mtx);x = 1;});t1.join();cout << x << endl;
}

在这里插入图片描述

unique_lock

比起lock_guard来说,unique_lock的逻辑和lock_guard的逻辑是一样的,都是调用类时加锁,出作用域时解锁,但是unique_lock比起lock_guard多加了几个条件,分别是:

  • 加锁/解锁操作:lock、try_lock、try_lock_for、try_lock_until和unlock。
  • 修改操作:移动赋值、swap、release(返回它所管理的互斥量对象的指针,并释放所有权)。
  • 获取属性:owns_lock(返回当前对象是否上了锁)、operator bool(与owns_lock的功能相同)、mutex(返回当前unique_lock所管理的互斥量的指针)。

具体实现逻辑,我们看下边的交替打印100的案例。

原子性操作库(atomic)

在上面的加锁案例中,多个线程同时对全局变量x进行++操作,并且,我们还对此操作进行了加锁,主要原因时++操作不是原子的,原子究竟是个什么呢?
原子大概的来说只有两个状态,一种是在运行,一种是不在运行,当处于在运行状态时,当其他进程进来时,发现为在运行状态就会进行等待, 知道状态模式换了才会进入下一个线程。

在这里插入图片描述

int main()
{int n = 100000;atomic<int> x = 0;//atomic<int> x = {0};//atomic<int> x{0};//int x = 0;mutex mtx;size_t begin = clock();thread t1([&, n](){for (int i = 0; i < n; i++){++x;}});thread t2([&, n]() {for (int i = 0; i < n; i++){++x;}});t1.join();t2.join();cout << x << endl;return 0;
}

在这里插入图片描述

条件变量库(condition_varuable)

condition_variable中提供的成员函数,可分为wait系列和notify系列两类。

wait系列成员函数(wait自带解锁功能和阻塞功能)

wait系列成员函数的作用就是让调用线程进行排队阻塞等待,包括waitwait_forwait_until

下面先以wait为例子,wait函数提供了两个不同版本的接口:

//版本一
void wait(unique_lock<mutex>& lck);
//版本二
template<class Predicate>
void wait(unique_lock<mutex>& lck, Predicate pred);

函数说明:

  • 调用第一个版本的时候,需要传入的参数只有一个互斥锁,线程调用wait后,就会进入阻塞状态,直到被唤醒
  • 调用第二个版本的时候,不仅要传入一个互斥锁,还需要传入一个返回值类型为bool的可调用对象,与第一个版本的wait不同的是,当线程被唤醒后还需要调用传入的可调用对象,如果可调用对象的返回值为false,那么该线程还需要继续被阻塞。

实际wait具有两个功能

  • 一个是让线程在条件不满足时进行阻塞等待,另一个是让线程将对应的互斥锁进行解锁。
  • 当线程被阻塞时这个互斥锁会被自动解锁,而当这个线程被唤醒时,又会自动获得这个互斥锁。

notify系列成员函数

notify系列成员函数的作用就是唤醒等待的线程,包括notify_one和notify_all。

  • notify_one:唤醒等待队列中的首个线程,如果等待队列为空则什么也不做。
  • notify_all:唤醒等待队列中的所有线程,如果等待队列为空则什么也不做。

综合案例(实现两个线程交替打印1-100)

尝试用两个线程交替打印1-100的数字,要求一个线程打印奇数,另一个线程打印偶数,并且打印数字从小到大依次递增。

该题目主要考察的就是线程的同步和互斥。

#include<mutex>
#include<condition_variable>int main()
{mutex mtx;condition_variable cv;int n = 100;int x = 1;// 问题1:如何保证t1先运行,t2阻塞?// 问题2:如何防止一个线程不断运行?thread t1([&, n]() {while (1){unique_lock<mutex> lock(mtx);if (x >= 100)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();}});thread t2([&, n]() {while (1){unique_lock<mutex> lock(mtx);if (x > 100)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();}});t1.join();t2.join();return 0;
}

在这里插入图片描述

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

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

一文读懂集合竞价,建议收藏,读完少交学费

集合竞价每个时间段交易规则及作用都不一样&#xff0c;了解集合竞价的规则&#xff0c;有利于琢磨主力的意图。 大部分同学都不是很关心集合竞价&#xff0c;也不知道如何利用集合竞价买卖股票。如上图所示&#xff0c;有同学在9点15看着股票涨停&#xff0c;立马冲进去&…

【切片】基础不扎实引发的问题

本次文章主要是来聊聊关于切片传值需要注意的问题&#xff0c;如果不小心&#xff0c;则很容易引发线上问题&#xff0c;如果不够理解&#xff0c;可能会出现奇奇怪怪的现象 问题情况&#xff1a; 小 A 负责一个模块功能的实现&#xff0c;在调试代码的时候可能不仔细&#x…

UE蓝图学习(从Unity3D而来)

一、UE组件对比Unity3D&#xff0c;从Unity3D过渡来学的角度出发&#xff0c;可以理解为在 空物体下放置子物体。UE没有U3D那种可以将组件挂在自身空物体上面。 二、UE 蓝图的情境提示&#xff0c;必须先找到相应的类型&#xff0c;对象对象、事件事件&#xff0c;才能找到相应…

云原生Kubernetes:Pod控制器

目录 一、理论 1.Pod控制器 2.Deployment 控制器 3.SatefulSet 控制器 4.DaemonSet 控制器 5.Job 控制器 6.CronJob 控制器 二、实验 1.Deployment 控制器 2.SatefulSet 控制器 3.DaemonSet 控制器 4.Job 控制器 5.CronJob 控制器 三、问题 1. showmount -e 报错…

QT窗口的设置、按钮的创建和对象树的概念

目录 设置窗口属性 按钮的创建 对象树 对象树的概念 构建和析构的顺序问题 设置窗口属性 在Qt官方手册中查找QWidget相关信息 或者在QT软件帮助一栏直接搜索QWidget 即可找到一些要寻找的设置属性的函数 将代码写在构造函数中 widget.cpp #include "widget.h"…

1200*A. Flipping Game(前缀和)

解析&#xff1a; 100数据量&#xff0c;两层遍历每个区间&#xff0c;然后前缀和计算1的个数&#xff0c;维护最大值即可。 #include<bits/stdc.h> using namespace std; #define int long long const int N110; int n,a[N],res,sum[N]; signed main(){scanf("%ll…

保姆级 -- Zookeeper超详解

1. Zookeeper 是什么(了解) Zookeeper 是一个 分布式协调服务 的开源框架, 主要用来解决分布式集群中应用系统的一致性问题, 例如怎样避免同时操作同一数据造成脏读的问题. ZooKeeper 本质上是 一个分布式的小文件存储系统 . 提供基于类似于文件系统的目录树方式的数据存储, …

常见的7种分布式事务解决方案(2pc,3pc,Tcc,Seta、本地事务....)

一 分布式事务 1.1 分布式事务 在分布式系统中一次操作需要由多个服务协同完成&#xff0c;这种由不同的服务之间通过网络协同完成的事务称为分布式事务。 1.首先满足事务特性&#xff1a;ACID 2.而在分布式环境下&#xff0c;会涉及到多个数据库 总结&#xff1a;分布式事务…

C运算符和控制语句

几乎每一个程序都需要进行运算&#xff0c;对数据进行加工处理&#xff0c;否则程序就没有意义了。要进行运算&#xff0c;就需规定可以使用的运算符。 C语言的运算符范围很宽&#xff0c;把除了控制语句和输人输出以外的几乎所有的基本操作都作为运算符处理。 运算符分类1 除…

Apache Flume

Flume 1.9.0 Developer Guide【Flume 1.9.0开发人员指南】 Introduction【介绍】 摘自&#xff1a;Flume 1.9.0 Developer Guide — Apache Flume Overview【概述】 Apache Flume is a distributed, reliable, and available system for efficiently collecting, aggregati…

Docker 安装Redis(集群)

3主3从redis集群配置 1、新建6个docker容器 redis 实例 docker run -d --name redis-node-1 --net host --privilegedtrue -v /data/redis/share/redis-node-1:/data redis:6.0.8 --cluster-enabled yes --appendonly yes --port 6381 docker run -d --name redis-node-2 --ne…

C理解(二):指针,数组,字符串,函数

本文主要探讨指针&#xff0c;数组&#xff0c;字符串&#xff0c;函数 指针 int *p; 未绑定:*表示p为指针变量,占4字节 int a 1;p &a; 绑定:p与a地址绑定即p中存放a的地址 *p *p 1; 解引用:p间接访问a的存储空间…

九、Delay函数

1、两个延时函数 vTaskDelay&#xff1a;至少等待指定个数的Tick Interrupt才能变为就绪态。vTaskDelayUntil&#xff1a;等待到指定的绝对时刻&#xff0c;才能变为就绪态。 2、函数原型 /* xTicksToDelay: 等待多少个Tick */ void vTaskDelay( const TickType_t xTicksToD…

Android Studio 的android.jar文件在哪儿

一般在&#xff1a;C:\Users\admin\AppData\Local\Android\Sdk\platforms\android-33下&#xff08;不一定是33&#xff0c;这个得看你Android Studio->app->builde.gradle的targetSdk是多少&#xff09; 怎么找&#xff1a; 1.打开Android Studio 粘贴地址后&#xff0…

13.(开发工具篇github)如何在GitHub上上传本地项目

一:创建GitHub账户并安装Git 二:创建一个新的仓库(repository) 三、拉取代码 git clone https://github.com/ainier-max/myboot.git git clone git@github.com:ainier-max/myboot.git四、拷贝代码到拉取后的工程 五、上传代码 (1)添加所有文件到暂存

Ci2451-2.4g无线MCU收发芯片

Ci2451 是一款集成无线收发器和8位RISC(精简指令集)MCU的SOC芯片。 无线MCU解决方案,集成丰富的MCU资源、更小尺寸,来满足设计中的各种内存、功率、尺寸要求,充分缩短2.4GHz无线产品设计周期并优化产品成本。 添加图片注释&#xff0c;不超过 140 字&#xff08;可选&#xff…

通讯网关软件016——利用CommGate X2Access实现OPC数据转储Access

本文介绍利用CommGate X2ACCESS实现从OPC Server读取数据并转储至ACCESS数据库。CommGate X2ACCESS是宁波科安网信开发的网关软件&#xff0c;软件可以登录到网信智汇(http://wangxinzhihui.com)下载。 【案例】如下图所示&#xff0c;实现从OPC Server读取数据并转储至ACCESS…

2023年【起重信号司索工(建筑特殊工种)】考试总结及起重信号司索工(建筑特殊工种)模拟考试题

题库来源&#xff1a;安全生产模拟考试一点通公众号小程序 起重信号司索工(建筑特殊工种)考试总结是安全生产模拟考试一点通生成的&#xff0c;起重信号司索工(建筑特殊工种)证模拟考试题库是根据起重信号司索工(建筑特殊工种)最新版教材汇编出起重信号司索工(建筑特殊工种)仿…

5、Docker安装mysql主从复制与redis集群

安装mysql主从复制 主从搭建步骤 1.1 新建主服务器容器实例3307 docker run -p 3307:3306 --name mysql-master #3307映射到3306&#xff0c;容器名为mysql-master -v /app/mysql/mydata/mysql-master/log:/var/log/mysql #容器数据卷 -v /app/mysql/mydata/mysql-master/dat…

[Linux]Git

文章摘于GitHub博主geeeeeeeeek 文章目录 1.1 Git 简易指南创建新仓库工作流添加与提交推送改动 1.2 创建代码仓库git init用法讨论裸仓库 例子 git clone用法讨论仓库间协作 例子用法讨论栗子 1.3 保存你的更改git add用法讨论缓存区 栗子 git commit用法讨论记录快照&#xf…