最近这段时间在学习C++多线程相关的知识,打算将学习的内容记录下来,加深理解和记忆。

C++11 新标准中引入了五个头文件来支持多线程编程,他们分别是<atomic> ,<thread>,<mutex>,<condition_variable><future>

  • <atomic>:该头文主要声明了两个类, std::atomic 和 std::atomic_flag,另外还声明了一套C风格的原子类型和与C兼容的原子操作的函数。
  • <thread>:该头文件主要声明了 std::thread 类,另外 std::this_thread 命名空间也在该头文件中。
  • <mutex>:该头文件主要声明了与互斥量(mutex)相关的类,包括 std::mutex 系列类,std::lock_guard, std::unique_lock, 以及其他的类型和函数。
  • <condition_variable>:该头文件主要声明了与条件变量相关的类,包括 std::condition_variable 和 std::condition_variable_any。
  • <future>:该头文件主要声明了 std::promise, std::package_task 两个 Provider 类,以及 std::future 和 std::shared_future 两个 Future 类,另外还有一些与之相关的类型和函数,std::async() 函数就声明在此头文件中。

定义

C++是在C++11之后才有了线程库:std::thread。编译时需要添加选项:-std=c++11。

使用std::thread创建线程比较简单,thread实例化一个线程对象就创建完成了,示例:

#include <iostream>
#include <thread>void f() { std::cout << "hello world";
}int main() {std::thread t{f};t.join();  // 等待新起的线程退出
}

main() 函数的是主线程,将函数f()添加为std::thread的参数即可启动另一个线程,两个线程会同时运行。

构造函数

默认构造函数 thread() noexcept;
初始化构造函数 template <class Fn, class… Args> explicit thread(Fn&& fn, Args&&… args);
拷贝构造函数 [deleted] thread(const thread&) = delete;
Move构造函数 thread(thread&& x) noexcept;
  • 默认构造函数,创建一个空的std::thread执行对象。
  • 初始化构造函数,创建一个std::thread对象,该std::thread对象可被joinable,新产生的线程会调用fn函数,该函数的参数由args给出。
  • 拷贝构造函数(被禁用),意味着std::thread对象不可拷贝构造。
  • Move构造函数,move构造函数(move语义是C++11新出现的概念),调用成功之后x不代表任何std::thread执行对象。

析构函数

~thread();

销毁thread对象。如果它还拥有关联线程(joinable() == true),则会调用std::terminate()结束程序。一般需要下列操作后,thread对象无关联的线程才可以安全销毁:

  • 被默认构造
  • 被移动(转移所有权)
  • 已调用join()
  • 已调用detach()

赋值操作函数

如果该对象还拥有关联的运行中进程(即joinable() == true),则调用std::terminate()终止程序。否则,赋值other的状态给该对象并设置other为默认构造的状态(空状态,不再执行线程)。

thread& operator=( thread&& other ) noexcept;

注意:该操作与move构造函数一样,属于“剪切”而非“拷贝”。

join与datch

join:阻塞当前线程直至thread对象所标识的线程结束其执行。

void join();

detach:从thread对象分离执行线程,允许独立地执行线程,主调线程无法再取得该线程的控制权。detach调用后不需要再调用join等待线程结束释放资源。一旦该线程退出,则自动释放所有分配的资源(它的资源会被init进程回收)。

void detach();

其它

joinable:检查对象是否还标识活跃的执行线程。具体就是,若get_id() != std::thread::id()则返回true,否则false 。默认构造的thread因为没有执行线程所以返回false。 结束执行代码,但仍未调用join函数的线程仍被当作活跃的执行线程,从而返回true。

bool joinable() const noexcept;

get_id:返回标识与当前thread对象关联的线程的std::thread::id。也就是返回线程的唯一标识。类std::thread::id是轻量的可频繁复制类,它是std::thread对象的唯一标识符。此类的实例也保留有不表示任何线程的特殊值。一旦线程结束,则std::thread::id的值可为另一线程复用。此类也可以用作有序和无序的关联容器的键值。

std::thread::id get_id() const noexcept;

native_handle:返回实现定义的底层线程句柄。允许通过使用平台相关API直接操作底层实现。具体值要依赖具体平台,对于Linux而言,即返回pthread的句柄。

native_handle_type native_handle();

hardware_concurrency:返回实现支持的并发线程数。应该只把该值当做提示。当无法获取时,函数返回0。

static unsigned int hardware_concurrency() noexcept;

基本用法

上面已经给出实例,传递一个函数指针就可以实例化一个线程,而std::thread的参数也可以使用有函数操作符类型的对象实例或者Lambda表达式进行构造:

#include <iostream>
#include <thread>struct A {// 函数操作符重载void operator()() const { std::cout << 1; }
};int main() {A a;std::thread t1(a);  // 1 会调用 A 的操作符()函数std::thread t2(A());  // 2 most vexing parse,声明名为t2参数类型为A的函数std::thread t3{A()};  // 3std::thread t4((A()));  // 4std::thread t5{[] { std::cout << 1; }};  // 5t1.join();t3.join();t4.join();t5.join();
}

对于上面语句①,通过有函数操作符类型的实例进行构造,也就是类型A的实例a,thread会将对象a复制到新线程的存储空间中,函数对象的执行和调用都在线程的内存空间中进行。

语句②,传递了一个临时变量A(),而不是一个命名的变量。C++编译器会将其解析为函数声明,而不是类型对象的定义。即t2是一个函数,原型是std::thread t2(A (*)()),这个函数带有一个参数(函数指针指向没有参数并返回A对象的函数),返回一个std::thread对象的函数。

为了避免出现类似语句②那样的语法失误,可以使用多组括号③,或使用统一的初始化语法④都可以避免。

语句⑤,Lambda表达式也能避免这个问题。Lambda表达式是C++11的一个新特性,允许使用一个可以捕获局部变量的局部函数。

线程参数

向可调用对象或函数传递参数很简单,只需要将这些参数作为std::thread构造函数的附加参数即可。需要注意的是,这些参数会拷贝至新线程的内存空间中(同临时变量一样)。即使函数中的参数是引用的形式,拷贝操作也会执行。来看一个例子:

#include <thread>
#include <string>
#include <iostream>void f(int i, std::string const& s) {std::cout << s << i << std::endl;
}int main() {std::thread t(f, 3, "hello");t.join();
}

上述代码创建了一个调用f(3, “hello”)的线程。注意,函数f需要一个std::string对象作为第二个参数,但这里使用的是字符串的字面值,也就是char const *类型,线程的上下文完成字面值向std::string的转化。

如果函数参数定义了默认实参则会被忽略,也就是必须要指定一个函数实参,即使有默认参数。

#include <iostream>
#include <thread>void f(int i = 1) {}int main() {// std::thread t{f};  // 出错,因为默认实参会被忽略std::thread t{f, 42};t.join();
}

如果参数是引用类型也会被忽略,如下面的代码,std::thread的构造函数①并不知晓函数f需要传入引用参数,直接无视函数参数类型,盲目地拷贝已提供的变量。不过,内部代码会将拷贝的参数以右值的方式进行传递,这是为了那些只支持移动的类型,而后会尝试以右值为实参调用f()。但因为函数期望的是一个非常量引用作为参数(而非右值),所以会在编译时出错。

#include <iostream>
#include <thread>void f(int& x) { ++x; }int main() {int i = 1;std::thread t{f, i}; // 1 compile errort.join();std::cout << i << std::endl;
}

问题的解决办法很简单:如果参数是引用类型要使用std::ref,使用std::ref将参数转换成引用的形式,这样函数f()就会收到i的引用,而非i的拷贝副本,例子如下:

#include <iostream>
#include <thread>void f(int& x) { ++x; }int main() {int i = 1;std::thread t{f, std::ref(i)};t.join();std::cout << i << std::endl;  // 输出 2
}

thread构建也可以传递一个成员函数指针作为线程函数,并提供一个合适的对象指针作为第一个参数:

#include <iostream>
#include <thread>struct A {
public:void do_work() {std::cout << "A::do_work\n";}
};int main() {A a;std::thread t(&A::do_work, &a);  // 1 t.join();
}

上面这段代码中,新线程将会调用a.do_work(),其中a的地址作为对象指针提供给函数。

这种情况也可以为成员函数提供参数:std::thread构造函数的第三个参数就是成员函数的第一个参数,以此类推:

#include <iostream>
#include <thread>struct A {
public:void do_work(int num) {std::cout << "A::do_work\n";}
};int main() {A a;int i = 1;std::thread t(&A::do_work, &a, i);  // 1 t.join();
}

另一种情况,为线程提供的入口函数的参数仅支持移动(move),不能拷贝。“移动”是指原始对象中的数据所有权转移给另一对象,从而这些数据就不再在原始对象中保存(类似文本编辑时的剪切操作)。std::unique_ptr就是这样一种类型(C++11中的智能指针),这种类型为动态分配的对象提供内存自动管理机制。同一时间内,只允许一个std::unique_ptr实例指向一个对象,并且当这个实例销毁时,指向的对象也将被删除。移动构造函数(move constructor)和移动赋值操作符(move assignment operator)允许一个对象的所有权在多个std::unique_ptr实例中传递。使用“std::move”转移对象所有权后,就会留下一个空指针。使用移动操作可以将对象转换成函数可接受的实参类型,或满足函数返回值类型要求。当原对象是临时变量时,则自动进行移动操作,但当原对象是一个命名变量,转移的时候就需要使用std::move()进行显示移动。下面的代码展示了std::move的用法,展示了std::move是如何转移动态对象的所有权到线程中去的:

#include <iostream>
#include <thread>struct big_object {
public:void prepare_data(int num) {std::cout << "big_object::prepare_data\n";}
};void process_big_object(std::unique_ptr<big_object> up) {std::cout << "process_big_object\n";
}int main() {std::unique_ptr<big_object> p(new big_object);p->prepare_data(42);std::thread t(process_big_object, std::move(p));  // 1 t.join();
}

通过在std::thread构造函数中执行std::move§,big_object对象的所有权首先被转移到新创建线程的的内部存储中,之后再传递给process_big_object函数。

等待线程完成(Join)

在线程销毁前要对其调用join等待线程退出或detach将线程分离,以下程序属于使用join正常等待线程退出,join属于阻塞式接口:

#include <iostream>
#include <thread>
#include <chrono>std::time_t now()
{auto t0 = std::chrono::system_clock::now();std::time_t time_t_today = std::chrono::system_clock::to_time_t(t0);return time_t_today;  // seconds
}void foo()
{// simulate expensive operationstd::this_thread::sleep_for(std::chrono::seconds(5));std::cout << now() << "-ending first helper...\n";
}void bar()
{// simulate expensive operationstd::this_thread::sleep_for(std::chrono::seconds(1));std::cout << now() << "-ending second helper...\n";
}int main()
{std::cout << "starting first helper...\n";std::thread helper1(foo);std::cout << "starting second helper...\n";std::thread helper2(bar);std::cout << now() << "-waiting for helpers to finish..." << std::endl;helper1.join();std::cout << now() << "-join return first helper...\n";helper2.join();std::cout << now() << "-join return second helper...\n";std::cout << "finish!\n";
}

输出

starting first helper...
starting second helper...
1645140155-waiting for helpers to finish...
1645140156-ending second helper...
1645140160-ending first helper...
1645140160-join return first helper...
1645140160-join return second helper...
finish!

使用detach分离线程,注意分离线程可能出现空悬引用的隐患:

#include <iostream>
#include <thread>
#include <chrono>class A {public:A(int& x) : x_(x) {}void operator()() {std::cout << "before sleep" << std::endl;std::this_thread::sleep_for(std::chrono::seconds(2));std::cout << "after sleep" << std::endl;call(x_);  // 存在对象析构后引用空悬的隐患}private:void call(int& x) {std::cout << x << std::endl;}private:int& x_;
};void f() {int x = 0;A a{x};  // 1 x的引用传递给A.0std::cout << "before t" << std::endl;std::thread t{a};  // 2std::this_thread::sleep_for(std::chrono::seconds(1));t.detach();  // 3 不等待 t 结束std::cout << "after t.detach" << std::endl;
}  // 4 函数结束后 t 可能还在运行,而 x 已经销毁,a.x_ 为空悬引用int main() {std::thread t{f};  // 5 导致空悬引用t.join();std::cout << "finish" << std::endl;std::this_thread::sleep_for(std::chrono::seconds(5)); // 6
}

输出:

before t
before sleep
after t.detach
finish
after sleep
0

如果std::thread线程结束后没有调用join释放资源,thread的析构函数会调用std::terminate终止程序,如下程序就会运行出错:

#include <thread>int main() {{std::thread t([] {});// t.join();}
}// terminate called without an active exception

join会在线程结束后清理std::thread所有资源,使其与完成的线程不再关联,因此对一个线程只能进行一次 join,如果调用多次会抛出异常:

#include <thread>int main() {std::thread t([] {});t.join();t.join();  // 错误throw excaption
}// 抛出的异常错误如下:
// minate called after throwing an instance of 'std::system_error'
// What():  Invalid argument

如果线程运行过程中发生异常(通常抛出异常要么会终止程序,要么跳转到捕获异常的位置),之后的join会被忽略,为此需要捕获异常,并在抛出异常前join:

#include <iostream>
#include <thread>int main() {std::thread t([] {});try {std::cout << "throw 0" << std::endl;throw 1;  // 1} catch (int x) {std::cout << "catch: " << x << std::endl;t.join();  // 2 处理异常前先 join()throw x;   // 3 再将异常抛出}std::cout << "last join" << std::endl;t.join();  // 4 之前抛异常,不会执行到此处
}

输出:

terminate called after throwing an instance of 'int'
throw 0
catch: 1

从上面的输出来看,根本不会执行到最后语句④,因为上面抛出异常程序就直接结束了,这个例子就是要说明thread创建的线程需要使用join()确保在线程完成后清理线程相关的资源,否则会引起程序异常。

特殊情况下的等待

对于上面发生异常导致程序终止的情况,很容易就忘记调用join,为了避免应用被抛出的异常所终止,通常我们使用另外一种方式解决该问题,封装一个类,在析构函数中使用join()。如同下面代码:

class thread_guard
{std::thread& t;
public:explicit thread_guard(std::thread& t_):t(t_){}~thread_guard(){if(t.joinable()) // 1{t.join();      // 2}}thread_guard(thread_guard const&)=delete;   // 3thread_guard& operator=(thread_guard const&)=delete;
};void th_func() {std::cout << "th_func" << std::endl;
}void f()
{std::thread t(th_func);thread_guard g(t);do_something_in_current_thread();
}    // 4int main() {f();
}

线程执行到④处时,局部对象就要被逆序销毁了。因此,thread_guard对象g是第一个被销毁的,这时线程在析构函数中被加入②到原始线程中。即使do_something_in_current_thread抛出一个异常,这个销毁依旧会发生。

在thread_guard析构函数的测试中,首先判断线程是否可汇入①。如果可汇入,会调用join()②进行汇入。

拷贝构造函数和拷贝赋值操作标记为=delete③,是为了不让编译器自动生成。直接对对象进行拷贝或赋值是很危险的,因为这可能会弄丢已汇入的线程。通过删除声明,任何尝试给thread_guard对象赋值的操作都会引发一个编译错误。

如果不想等待线程结束,可以分离线程,从而避免异常。不过,分离操作即使能让线程仍然在后台运行着,也能确保在std::thread对象销毁时不调用std::terminate()。但这会打破了线程与std::thread对象的联系,也就是外部无法通过thread实例对象操作控制线程了。

转移所有权

std::thread是move-only类型,不能拷贝,只能通过移动转移所有权(复制构造函数已被删除),但不能转移所有权到joinable的线程,因为每个线程thread实例都是唯一的,没有两个std::thread对象会表示同一执行线程。

#include <thread>
#include <cassert>
#include <utility>void f() {}
void g() {}int main() {std::thread t1{f};  // 1std::thread t2 = std::move(t1);  // 2assert(!t1.joinable());assert(t2.joinable());t1 = std::thread{g}; // 3assert(t1.joinable());assert(t2.joinable());
//   t1 = std::move(t2);  // 4 错误,不能转移所有权到 joinable 的线程t1.join();t1 = std::move(t2);  // 5 assert(t1.joinable());assert(!t2.joinable());t1.join();
}

首先,新线程与t1相关联①。当显式使用std::move()创建t2后②,t1的所有权就转移给了t2。之后,t1和执行线程已经没有关联了,执行f的函数线程与t2关联。

然后,临时std::thread对象相关的线程启动了③。为什么不显式调用std::move()转移所有权呢?因为,所有者是一个临时对象——移动操作将会隐式的调用

将t2线程的所有权转移④给t1。不过,t1已经有了一个关联的线程(执行g的线程),所以这里系统直接调用std::terminate()终止程序继续运行。这样做(不抛出异常,std::terminate()noexcept函数)是为了保证与std::thread的析构函数的行为一致。需要在线程对象析构前,显式的等待线程完成,或者分离它,进行赋值时也需要满足这些条件(说明:不能通过赋新值给std::thread对象的方式来"丢弃"一个线程)。

可以看到调用join释放线程资源后就可以使用move转移所有权了⑤。

  • 移动操作同样适用于支持移动的容器
#include <algorithm>
#include <thread>
#include <vector>int main() {std::vector<std::thread> v;for (int i = 0; i < 10; ++i) {v.emplace_back([] {});}std::for_each(std::begin(v), std::end(v), std::mem_fn(&std::thread::join));
}

将std::thread放入std::vector是向线程自动化管理迈出的第一步:并非为这些线程创建独立的变量,而是把它们当做一个组。创建一组线程(数量可以在运行时确定)。

  • std::thread 可以作为函数返回值
#include <thread>std::thread f() {return std::thread{[] {}};
}int main() {std::thread t{f()};  // f函数返回的thread移交给了tt.join();
}
  • std::thread 也可以作为函数参数
#include <thread>
#include <utility>void f(std::thread t) { t.join(); }int main() {f(std::thread([] {}));std::thread t([] {});f(std::move(t));
}

实现一个可以直接用std::thread构造的自动清理线程的类

#include <stdexcept>
#include <thread>
#include <utility>class scoped_thread {public:explicit scoped_thread(std::thread x) :   // 1t_(std::move(x)) {if (!t_.joinable()) {  // 2throw std::logic_error("no thread");}}~scoped_thread() { t_.join();  // 3}scoped_thread(const scoped_thread&) = delete;scoped_thread& operator=(const scoped_thread&) = delete;private:std::thread t_;
};void f() {scoped_thread t{std::thread{[] {}}};  // 4
}  // 5int main() {f();
}

与上面实现的thread_guard相似,不过新线程会直接传递到scoped_thread中④,而非创建一个独立变量。当主线程到达f()末尾时⑤,scoped_thread对象就会销毁,然后在析构函数中完成汇入③。上面的thread_guard类,需要在析构中检查线程是否“可汇入”。这里把检查放在了构造函数中②,并且当线程不可汇入时抛出异常。

线程标识

线程标识为std::thread::id类型,可以通过两种方式进行检索。

  • 可以通过调用std::thread对象的成员函数get_id()来直接获取。如果std::thread对象没有与任何执行线程相关联,get_id()将返回std::thread::type默认构造值(一般是0),这个值表示“无线程”。
  • 在当前线程中调用std::this_thread::get_id()(这个函数定义在头文件中)也可以获得线程标识。

std::thread::id对象可以自由的拷贝和对比,因为标识符是唯一的。如果两个对象的std::thread::id相等,那就是同一个线程,或者都“无线程”。如果不等,那么就代表了两个不同线程,或者一个有线程,另一没有线程。

C++线程库不会限制你去检查线程标识是否一样,std::thread::id类型对象提供了相当丰富的对比操作。比如,为不同的值进行排序。这意味着开发者可以将其当做为容器的键值做排序,或做其他比较。按默认顺序比较不同的std::thread::id:当a<bb<c时,得a<c,等等,标准库也提供std::hash<std::thread::id>容器,std::thread::id也可以作为无序容器的键值。

#include <iostream>
#include <thread>
#include <vector>void th_func() {std::cout << "sub_thread id2: " << std::this_thread::get_id() << std::endl;
}void f()
{std::thread t_null;std::cout << "null_thread id: " << t_null.get_id() << std::endl;std::thread t(th_func);std::cout << "sub_thread id1: " << t.get_id() << std::endl;t.join();
}    // 4int main() {std::thread::id main_thread = std::this_thread::get_id();std::cout << "main_thread id: " << main_thread << std::endl;f();std::vector<std::thread> threads(3);  // 5for(int i=0; i < 3; ++i){threads[i]=std::thread([i] () {std::cout << i << std::endl;});}for (auto& it : threads) {it.join();}
}

输出:

main_thread id: 140672360355648
null_thread id: thread::id of a non-executing thread
sub_thread id1: 140672360351488
sub_thread id2: 140672360351488
0
2
1

查看硬件支持的线程数量

std::thread::hardware_concurrency()在新版C++中非常有用,其会返回并发线程的数量。例如,多核系统中,返回值可以是CPU核芯的数量。返回值也仅仅是一个标识,当无法获取时,函数返回0。

#include <iostream>
#include <thread>int main() {unsigned int n = std::thread::hardware_concurrency();std::cout << n << " concurrent threads are supported.\n";
}

std::thread到这里基本完成了,接口不是很多,但是用法细节挺多的。学习时最好都能用一个简单例子跑一遍。

参考:

《C++ Concurrency In Action》

std::thread - C++中文 - API参考文档 (apiref.com)

C++多线程:std::thread相关推荐

  1. c+++11并发编程语言,C++11并发编程:多线程std:thread

    原标题:C++11并发编程:多线程std:thread 一:概述 C++11引入了thread类,大大降低了多线程使用的复杂度,原先使用多线程只能用系统的API,无法解决跨平台问题,一套代码平台移植, ...

  2. C++11并发编程:多线程std::thread

    一:概述 C++11引入了thread类,大大降低了多线程使用的复杂度,原先使用多线程只能用系统的API,无法解决跨平台问题,一套代码平台移植,对应多线程代码也必须要修改.现在在C++11中只需使用语 ...

  3. 【多线程】C++11进行多线程开发 (std::thread)

    文章目录 创建线程 std::thread 类 使用join() 使用 detach() 警惕作用域 线程不能复制 给线程传参 传递指针 传递引用 以类成员函数为线程函数 以容器存放线程对象 互斥量 ...

  4. [C++11 std::thread] 使用C++11 编写 Linux 多线程程序

    From: http://www.ibm.com/developerworks/cn/linux/1412_zhupx_thread/index.html 本文讲述了如何使用 C++11 编写 Lin ...

  5. C++11新特性以及std::thread多线程编程

    一 .C++11新特性 1. auto 类型推导 1.1 当=号右边的表达式是一个引用类型时,auto会把引用抛弃,直接推导出原始类型: 1.2 当=号右边的表达式带有const属性时,auto不会使 ...

  6. C++多线程编程实战01:std::thread

    C++多线程:std::thread 文章目录 C++多线程:std::thread 定义 构造函数 析构函数 赋值操作函数 join与datch 例子 例子 其它 基本用法 线程参数 等待线程完成( ...

  7. C++11 多线程(std::thread)详解

    注:此教程以 Visual Studio 2019 Version 16.10.3 (MSVC 19.29.30038.1) 为标准,大多数内容参照cplusplus.com里的解释 此文章允许转载, ...

  8. C++多线程:thread类创建线程的多种方式

    文章目录 描述 函数成员简介 总结 描述 头文件 <thread> 声明方式:std::thread <obj> 简介 线程在构造关联的线程对象时立即开始执行,从提供给作为构造 ...

  9. C++11学习笔记-----线程库std::thread

    在以前,要想在C++程序中使用线程,需要调用操作系统提供的线程库,比如linux下的<pthread.h>.但毕竟是底层的C函数库,没有什么抽象封装可言,仅仅透露着一种简单,暴力美 C++ ...

最新文章

  1. Wex5铛铛开发环境搭建步骤
  2. ITK:KMeans聚类
  3. mysql-常用sql
  4. nssl1478-题【dp】
  5. 【渝粤教育】电大中专电子商务网站建设与维护 (22)作业 题库
  6. 没看过这10本程序员必读烧脑经典,别说你是敲代码的
  7. 如何优雅的关闭 Spark Streaming 程序(2种思路)
  8. register关键字-1
  9. 四川职称计算机英语,四川职称计算机考试报名细则
  10. leetcode之四数相加
  11. Google Play 应用迁移
  12. Css3中align-content,css align-content属性怎么用
  13. python爬虫GUI工具,tkinter网易云歌单歌曲下载器
  14. ios文件和文件夹管理
  15. cf端游界面更新显示服务器繁忙,电脑登录cf老是显示更新失败怎么办
  16. 微信html5展示页,H5科普|微信H5页面的展示形式
  17. Ant Mobile使用整理
  18. 腾讯云CVM使用体验
  19. XT.COM关于Coinzilla AMA直播回顾
  20. WebGL自学课程(6):WebGL加载跨域纹理出错Uncaught Error: SECURITY_ERR: DOM Exception 18

热门文章

  1. php考研大学,2019考研:49所院校公布研究生招生简章及专业目录
  2. dorado的autoform控件赋值、取值
  3. 在centos7下安装python3.7.9并搭建scrapy2环境
  4. Hot_s 三子一线棋
  5. html galgame引擎,GitHub - kasuganosora/Reitsuki: Html5 GalGameEngine
  6. 阻焊层solder mask助焊层paste mask
  7. 非开挖管道修复中常用的材料树脂,环氧树脂,聚酯纤维有什么区别
  8. 互联网公司的应届生情况
  9. 关于VS2019不能打开源文件的解决方法
  10. 国信长天嵌入式竞赛平台及扩展板硬件资源布局介绍