当前位置: 首页 > news >正文

【Linux系统编程】线程--控制

目录

pthread库/原生线程库

线程创建

线程等待

线程终止

线程资源私有与共享

可重入与不可重入函数

多线程应用

线程分离

pthread线程


pthread库/原生线程库

书接上文,在线程概念中我说过,Linux的内核中没有单独实现线程,而是用进程控制块模拟的线程,所以Linux中只有轻量级进程

正因为没有单独设计线程,所以就不会提供线程的系统调用,Linux只会提供轻量级进程的系统调用

但现在的问题是,轻量级进程只是一款操作系统中的特殊情况,它不具有普适性,操作系统中只有进程和线程,而没有说明什么叫做轻量级进程,对于不了解Linux内核的用户来说不是非常友好。

所以Linux的工程师们为了普适性,所以他们将轻量级进程的系统调用进行了封装成了一个第三方库,转成线程相关的接口语义提供给用户

而他封装完成以后的库,我们称之为pthread库,也称之为原生线程库

原生线程库是每一款Linux安装时都必须自带的一个库

当用户在使用pthread库时,他不需要知道底层是轻量级进程,用户只需要当成是线程即可!但实际上底层其实封装的轻量级进程

并且还要注意的一个点是pthread库不是内核提供的库,而是在用户层封装的一个库,所以我们一般也把Linux中的线程称之为用户级线程,也正是因为pthread库是一个用户级封装的库,所以我们使用gcc/g++编译代码的时候要链接上pthread库,也就是带上-lpthread选项

线程创建

首先要介绍的pthread库中的第一个函数就是pthread_create,如下

SYNOPSIS#include <pthread.h>int pthread_create(pthread_t *thread, const pthread_attr_t *attr,void *(*start_routine) (void *), void *arg);
  • pthread_create的功能是创建一个新的线程
  • pthread_create的第一个参数thread是一个输出型参数,保存的是创建的新的线程的标识符
  • pthread_create的第二个参数attr指向一个pthread_attr_t类型的值,而这个类型中主要存储的是线程的属性,所以attr是为你的新线程设置一个属性,一般设置为nullptr表示默认属性
  • pthread_create的第三个参数start_routine是一个函数指针,表示你要分配给新线程执行的代码块,函数参数类型和返回值类型都是固定的void *
  • pthread_create的第四个参数arg表示的是start_routine的参数的值
  • pthread_create的返回值是如果创建新线程成功返回0,如果失败返回错误码并且thread参数是未定义的

如下为一个简单的demo代码:

#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <pthread.h>void* NewThread(void* arg)
{while(true){sleep(1);std::cout << "I am new thread , pid :" << getpid() << std::endl;}return nullptr;
}int main()
{pthread_t tid;pthread_create(&tid,nullptr,NewThread,nullptr);//主线程执行之后的代码while(true){std::cout << "I am main thread , pid :" << getpid() << std::endl;sleep(1);}return 0;
}

运行结果:

I am main thread , pid :27415
I am new thread , pid :27415
I am main thread , pid :27415
I am main thread , pid :I am new thread , pid :2741527415I am new thread , pid :27415
I am main thread , pid :27415
I am new thread , pid :27415I am main thread , pid :
27415
I am new thread , pid :27415
I am main thread , pid :27415
I am new thread , pid :27415
I am main thread , pid :27415

首先,上述的运行结果我们能看出几个特点

1、有两个执行流,原因是代码中两个死循环,想要同时执行两个死循环只能是有两个执行流

2、两个执行流的pid都相同,这说明两个执行流属于同一个进程

除此之外,可能还有一些数据紊乱的问题,这个问题需要我们到线程同步和互斥章节再解决


查看线程指令

我们是否有方法在代码执行的时候观察到两个执行流的标识符呢?

实际上是有的,如下指令

ps -aL

-L选项适用于显示Linux轻量级进程的信息。

我编写的可执行程序的名字是testthread

[yyf@VM-24-5-centos 24.7.26]$ ps -aLPID   LWP TTY          TIME CMD
29074 29074 pts/0    00:00:00 testthread
29074 29075 pts/0    00:00:00 testthread
29108 29108 pts/5    00:00:00 ps

我们可以看到, 两个testthread执行流,它们的pid相同,说明属于同一个进程,除此之外我们还能看到LWP,LWP全称light weight process(轻量级进程),LWP就是Linux中轻量级进程的标识符,而主执行流的PID和LWP是相同的

注意:CPU调度的时候实际上是根据LWP来调度的


线程标识符 vs LWP

那么之前我们在使用pthread_create的时候第一个参数就是线程的标识符,线程标识符和LWP是同一个吗?于是代码修改为如下

void* NewThread(void* arg)
{return nullptr;
}
int main()
{pthread_t tid;pthread_create(&tid,nullptr,NewThread,nullptr);//主线程执行之后的代码while(true){std::cout << "I am main thread , new thread tid :" << tid << std::endl;sleep(1);}return 0;
}

运行结果:

[yyf@VM-24-5-centos 24.7.26]$ ./testthread 
I am main thread ,new thread tid :139773420365568
I am main thread ,new thread tid :139773420365568
I am main thread ,new thread tid :139773420365568

我们能明显看到,这个线程标识符不太可能是线程的LWP

实际上,LWP是内核中用于标识一个轻量级进程的标识符,它是内核中维护的

而pthread库中的标识符是用户级进行维护的,它们之间的维护方案不同其实也正常

但不管如何pthread库中的标识符和LWP之间的关系一定是一对一映射的,至于库中的标识符到底是什么,我们得到后面才能解释


获取线程标识符

线程能不能获取自己的线程标识符呢?

实际上是可以的,所以接下来介绍一个函数:pthread_self

SYNOPSIS#include <pthread.h>pthread_t pthread_self(void);

pthread_self的功能是获取当前线程的标识符

没有参数,返回值是pthread_t类型,说明获取的其实是用户级标识符,也就是库中维护的标识符

这个函数较为简单,也就不提供demo代码了


线程参数的泛型化

回到线程创建,之前我们说的线程执行的函数的参数和返回值都是void*的,在C语言中这其实表示的是可以接收任意类型,那么我们是否可以给这个线程传递一个字符串呢?实际上是可以的,如下为demo代码:

#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <pthread.h>
#include <string>
void *NewThread(void *arg)
{std::string threadname = (char *)arg;while (true){std::cout << "I am new thread , My name : " << threadname << std::endl;sleep(1);}return nullptr;
}int main()
{pthread_t tid;pthread_create(&tid, nullptr, NewThread, (void *)"thread-1");// 主线程执行之后的代码while (true){std::cout << "I am main thread" << std::endl;sleep(1);}return 0;
}

运行结果: 

[yyf@VM-24-5-centos 24.7.26]$ ./testthread 
I am main thread
I am new thread , My name : thread-1
I am main thread
I am new thread , My name : thread-1
I am main threadI am new thread , My name : 
thread-1
I am main thread
I am new thread , My name : thread-1
I am main thread
I am new thread , My name : thread-1

可以看到,新线程确实可以接收到一个字符串,实际上新线程不仅仅可以接收到字符串,其他类型也能。同理返回值也可以是任意类型

线程等待


线程等待的必要性 

接下来的一个问题是,主线程和新线程都是执行流,如果主线程比新线程先执行完退出了,会出现什么情况呢?

实际上,主线程如果退出了就相当于进程退出,进程退出会释放自身的资源,包括地址空间、页表,还包括当前进程中的所有task_struct,所以主进程如果退出,所有的新线程也会退出

所以这也就导致了我们编写代码的时候不应该让主线程比新线程早退出,并且如果主线程先退出,那么就会出现类似父进程创建子进程,父进程先退出的内存泄漏问题,所以线程等待是有必要的!

除此之外,一个新线程执行完代码以后我们是否需要拿到它的返回值呢,肯定是需要的

所以,接下来介绍一个函数:pthread_join

SYNOPSIS#include <pthread.h>int pthread_join(pthread_t thread, void **retval);
  • pthread_join的功能是等待一个新线程退出
  • pthread_join的第一个参数thread是一个用户级线程标识符,表示你要等待哪个新线程退出
  • pthread_join的第二个参数retval是一个输出型参数,表示新线程执行完以后的返回值
  • pthread_join的返回值是如果成功返回0,如果失败返回错误码

如下为demo代码:

void* threadrun(void* arg)
{sleep(1);return (void*)123;
}int main()
{pthread_t tid;pthread_create(&tid,nullptr,threadrun,nullptr);void* ret = nullptr;pthread_join(tid,&ret);std::cout << "new thread retval : " << (long long)ret << std::endl;
}

这段代码主要验证主线程确实等了新线程,如果没有等的话由于新线程睡眠了一秒所以主线程一定比新线程先退出,而主线程退出相当于进程退出,所以新线程不会有返回值,而如果等了,那么新线程就有返回值,且返回值为123

运行结果:

[yyf@VM-24-5-centos 24.7.26]$ ./testthread 
new thread retval : 123

所以,我们能拿到新线程的返回值也就意味着我们可以给新线程定义一个退出码,有了退出码也就能得到代码运行完,结果正不正确这个信息


进程退出 vs 线程退出 

我们之前说的进程退出有三种情况:

1、代码运行完、结果正确

2、代码运行完、结果不正确

3、异常退出

线程和进程是非常相似的,因为Linux中的线程是用进程模拟的

而线程已经能通过返回值表示出前两种情况,那么对于第三种情况异常退出的话会怎么样呢?

实际上每一个线程都是属于进程内部的,那么一个线程异常了也就说明整个进程出现了异常,所以不论是主线程还是新线程只要有一个异常退出了,那么进程也就终止了,进程终止就意味着所有资源都被释放了,所以一般多线程代码的健壮性较差

一个线程出问题,导致其他线程也出问题,导致整个进程退出,我们称这种为线程安全问题

也正是因为进程内部一个线程出问题,整个进程都终止的特点,所以对于线程我们一般不考虑异常终止这种情况,因为主线程拿不到新线程的异常信息就已经退出了


为什么没有线程间通信?

 之前我们在线程概念说过,一个进程中的多个线程实际上是共享的同一个进程地址空间,只不过是把代码段分给不同的线程而已!

那么这也就意味着,不同的线程实际上是共享地址空间中的大部分资源的,当然也有特殊情况,特殊情况先不考虑,所以实际上对于线程来说根本就不需要线程间通信的,因为多个线程本身就能看到同一个资源

如下代码:

int g_val = 1;void* running(void* arg)
{while(true){g_val++;sleep(1);}
}int main()
{pthread_t tid;pthread_create(&tid,nullptr,running,nullptr);while(true){std::cout << "g_val : " << g_val << std::endl;sleep(1);}return 0;
}

新线程只做g_val++,主线程打印g_val,如下运行结果:

[yyf@VM-24-5-centos 24.7.26]$ ./testthread 
g_val : 1
g_val : 2
g_val : 3
g_val : 5
g_val : 6
g_val : 7
g_val : 8
g_val : 9
g_val : 10

可以看到,两个线程访问的是一个公共的资源

线程终止

以上我们了解了线程的创建和等待,而接下来我们要了解的是线程的退出

线程退出一共有如下方式

1、线程函数结束,线程退出

2、特定函数退出


线程退出方法:线程自身终止自己

需要注意的是,我们不能使用exit来终止一个线程,因为exit是用于进程终止的

pthread库中提供了一个函数用于线程退出,pthread_exit

SYNOPSIS#include <pthread.h>void pthread_exit(void *retval);
  • pthread_exit的功能是终止当前调用此函数的线程
  • pthread_exit的参数是退出信息,void*表示可以是任意类型

如下demo代码:

void *running(void *arg)
{int cnt = 5;while (true){if (!cnt){std::cout << "new thread quit!" << std::endl;pthread_exit((void *)123);}std::cout << "I am new thread,running...." << "cnt:" << cnt << std::endl;cnt--;sleep(1);}
}int main()
{pthread_t tid;pthread_create(&tid, nullptr, running, nullptr);void *retval = nullptr;pthread_join(tid, &retval);std::cout << "new thread return val :" << (long long)retval << std::endl;return 0;
}

 运行结果:

I am new thread,running....cnt:5
I am new thread,running....cnt:4
I am new thread,running....cnt:3
I am new thread,running....cnt:2
I am new thread,running....cnt:1
new thread quit!
new thread return val :123

 线程终止方式:主线程终止新线程

上述的pthread_exit是用于线程自己调用然后自己退出的,那么能不能让主线程控制新线程退出呢? 

实际上是可以的,pthread库中提供了一个函数,pthread_cancel

SYNOPSIS#include <pthread.h>int pthread_cancel(pthread_t thread);
  • pthread_cancel的功能是取消一个线程,一般是主线程取消新线程
  • pthread_cancel的参数是你要取消的线程的用户级标识符
  • pthread_cancel的返回值如果取消成功返回0,取消失败返回错误码

注意:调用pthread_cancel时,要保证新线程还在运行

如下为demo代码:

void *running(void *arg)
{while (true){std::cout << "I am new thread,running...." << std::endl;sleep(1);}return nullptr;
}int main()
{pthread_t tid;pthread_create(&tid, nullptr, running, nullptr);void *retval = nullptr;sleep(5);pthread_cancel(tid);pthread_join(tid, &retval);std::cout << "new thread return val :" << (long long)retval << std::endl;return 0;
}

运行结果:

I am new thread,running....
I am new thread,running....
I am new thread,running....
I am new thread,running....
I am new thread,running....
new thread return val :-1

从上面的运行结果我们能看出,如果一个线程是被取消的话,那么它的退出信息就是-1

(void*)-1就相当于宏PTHREAD_CANCELED

上面我们已经介绍了线程的创建、终止、等待,下面需要为了更深入的理解线程,我们接下来该介绍线程的理论部分!

线程资源私有与共享


线程私有资源有哪些? 

首先,进程在设计之初其实是比较强调的进程的独立性,这就要求进程互相之间不能影响对方,进程间通信除外

而与之相对的线程在设计之初更强调的是共享进程的资源,但无论线程如何共享资源,总有一些属性是线程所独立的,那么有哪些呢?如下

  • 线程ID(用户级标识符/内核级标识符)
  • 硬件上下文数据(CPU寄存器的值)
  • 独立栈
  • 错误码
  • 信号屏蔽字
  • 调度优先级

 线程为什么要有独立栈?

线程本质上是执行的自己的函数代码块,这从我们线程创建中也可以得知,而函数代码块中可能定义了许多变量,之前我们说函数中的临时变量都是被保存在地址空间的栈中的。

那么如果此时有10个线程,每一个都有自己的临时变量,它们定义的临时变量可能随时都入栈,每一个都变量都要压栈,那么此时就会出问题,你定义了一个栈,我甚至都不知道你是哪个函数内部定义的,也就是说我甚至不知道你这个变量是哪个线程定义的,那么显然就是不合理的

所以,pthread库在设计之初,就必须保证给每一个线程一个独立的栈结构,这个独立栈是在pthread库中维护的,不属于内核


线程共享资源有哪些? 

当然,线程更强调的是共享,所以上面的这些线程自己私有的资源与共享的资源相比是较少的,由于线程共享同一个地址空间,所以数据段和代码段都是共享的,也就是说如果某个线程定义了一个函数,其他线程其实都可以调用这个函数,如果定义了一个全局变量,其他进程都能访问到同一个全局变量,当然除了上述之外,各线程还共享的较容易忽略的进程资源如下:

  • 文件描述符表
  • 每种信号的处理方式(实际上就是handler表)
  • 当前工作目录(进程cwd)
  • 用户id和组id

可重入与不可重入函数

线程之间可以共享同一个函数,所以在多线程中,公共函数如果被多个线程同时进入,那么我们称该函数被重入

  • 如果一个函数被重入不会出现问题,那么我们称这个函数为可重入函数
  • 如果一个函数被重入会出现问题,那么我们称这个函数为不可重入函数

多线程应用

我们之前的线程控制都是只有两个执行流,一个主线程和一个新线程,但一个进程中能不能有很多个进程呢?实际上是可以的,而有了以上理论的铺垫,我们可以编写一个多线程的代码

编写多线程代码的注意事项:

  • 多线程代码中线程肯定是需要被管理起来的,需要对线程进行先描述后组织,描述即对线程标识符进行封装,组织则使用特定数据结构组织起来即可
  • 线程创建时它的返回值和参数都是void*类型的,也就意味着不管是返回值还是参数都可以传任意类型,包括自定义对象

场景 

所以我们可以可以模拟一个场景,主线程创建了5个新线程,它们所对应的线程函数的参数都是一个对象,对象中包含了任务,之后执行任务,把任务结果用类封装起来,主线程最后统一管理任务结果


代码 

如下代码:

class Task
{
public:Task(int x, int y) : _x(x), _y(y){}int Execute(){return _x + _y;}protected:int _x;int _y;
};
class ThreadDate : public Task
{
public:ThreadDate(int x, int y, std::string &name): Task(x, y), _name(name){}int Execute(){return Task::Execute();}std::string name(){return _name;}protected:std::string _name;
};
class Result
{
public:Result(int result, std::string name) : _result(result), _name(name){}int _result;std::string _name;
};
void *handler(void *args)
{ThreadDate *td = (ThreadDate *)args;int retVal = td->Execute();Result* r = new Result(retVal,td->name());return (void*)r;
}int main()
{std::vector<pthread_t> threads;for (size_t i = 0; i < 5; ++i){// 主线程创建五个新线程,传入线程名pthread_t tid;std::string name = "thread-" + std::to_string(i + 1);ThreadDate *td = new ThreadDate(10, 20, name);int n = pthread_create(&tid, nullptr, handler, (void *)td);if (n){perror("create thread error!");std::cout << "errno:" << n << strerror(n) << std::endl;exit(-1);}threads.push_back(tid);}std::vector<Result*> val;//主线程统一收集线程的执行结果for (auto &thread : threads){void* ret = nullptr;int n =pthread_join(thread, &ret);if(n){perror("join thread error!");std::cout << "errno:" << n << strerror(n) << std::endl;exit(1);}val.push_back((Result*)ret);}//打印统计好的执行结果for(auto& retVal : val){std::cout << "thread name : " << retVal->_name << " return val " << retVal->_result << std::endl;delete retVal;}return 0;
}

运行结果:

thread name : thread-1 return val 30
thread name : thread-2 return val 30
thread name : thread-3 return val 30
thread name : thread-4 return val 30
thread name : thread-5 return val 30

线程分离


线程分离的引入 

之前我们说过一个新线程被创建出来以后,主线程需要等待新线程,不然会发生类似于僵尸进程的内存泄漏问题,但主线程在等待新线程的期间,主线程处于阻塞状态。这也就意味着主线程等待期间无法做其他的事,那么就可能导致效率问题,所以有没有什么办法即可以解决新线程退出时的内存泄漏问题,又可以让主线程不必处于阻塞状态呢?

实际上是有的,我们只需要把新线程设置为分离状态即可


 线程分离的方法

线程分离的函数是pthread_detach

SYNOPSIS#include <pthread.h>int pthread_detach(pthread_t thread);
  • pthread_detach的参数是你要设置为分离状态的线程的用户级标识符
  • pthread_detach的返回值如果设置成功返回0,设置失败返回错误码

分离线程的特点

分离状态的线程有如下特点:

1、当被分离的线程退出时,会自动释放线程资源,不需要也不允许等待该线程,如果强行对分离线程进行等待,那么等待会返回错误码

2、无法获得分离线程的返回值

3、分离线程于非分离的线程之间除了被分离线程退出会自动释放资源以外,其他并无差别,这就说明当主线程退出时,被分离的线程也会自动退出。分离线程出现异常,整个进程退出,所以被分离的线程底层其实还是属于进程!

我们再回想一下线程等待的意义,线程等待除了回收资源和拿到返回值以外,更前提的是保证了主线程比新线程要晚退出。所以如果把线程设置为分离,那么就要保证主线程比新线程要晚退出,而一般把新线程设置为分离的情况是主线程处于死循环


pthread线程

 之前,我们说过pthread库中维护的线程id和我们内核中轻量级进程的id是不同的,那么pthread库中的线程id究竟是如何得来的呢?


什么是线程id 

实际上,pthread库中维护的线程id本质实际是一个地址,我们可以打印出来看一看

#include <iostream>
#include <pthread.h>//十进制整数转16进制
std::string ToHex(pthread_t tid)
{char buffer[64];snprintf(buffer,64,"0x%lx",tid);return buffer;
}int main()
{std::cout << "tid : " << ToHex(pthread_self()) <<std::endl;return 0;
}

运行结果:

tid : 0x7f6baaa7d740

所以这是一个什么地址呢?


pthread库的本质 

之前我们说过,Linux内核中只有轻量级进程而没有线程,但是用户在使用Linux系统时是否有线程的概念呢?是否可以管理一个线程?比如创建线程、等待线程...

显然是可以的,那么Linux系统是如何做到的呢?实际上也就是封装了一个pthread库,用户在使用pthread库时,底层使用的其实是轻量级进程,但用户不关心底层,他看到的是线程!

那么pthread库本质上不就是磁盘上的一个文件吗?我们需要用这个库,那么就需要跟其他文件一样,先把pthread库加载到内存中,这是其一

其次,pthread库是一个动态库,它还需要映射到当前进程地址空间的共享区中。

所以当进程调用pthread库函数时,就跳转到自己的地址空间的共享区中,调用结束以后再回到正文代码,这样就完成了一次对于库函数的调用


pthread库如何管理线程? 

所以Linux内核不提供线程,也就意味着它不会对线程进行管理,但用户在使用的时候又能使用线程,那么线程是否需要管理呢?是否需要创建、删除、等待、分离呢?是否需要知道线程的id和线程的优先级呢?

显然需要,那么谁来对线程进行管理呢?

答案是pthread库中来管理线程,pthread库如何管理线程呢?

答案是先描述,后组织!所以pthread库中的结构应该如下图:

 其中,pthread结构体我们可以当成是Linux中的TCB,它里面肯定有很多对线程属性,不管是什么属性,毋庸置疑的是它里面一定对Linux中的LWP进行了封装

而我们也能看出,在前文中所说的线程的独立栈结构实际上也是在库中维护的

而库中对一个线程的描述实际上是包含了pthread结构体,线程局部存储,线程栈!实际上我们可以把这三部分当成一个结构化的数据,它们都是供一个线程使用的!并且所有线程的结构化数据都被数据结构组织了起来,此时对线程的管理就变成了对数据结构的增删查改

实际上,所谓的线程tid也就是线程标识符实际上就是这个结构化数据的起始地址,通过线程tid就能找到对应线程的所有属性,就能实现对线程的管理

当然,pthread库是一个动态库,也称之为共享库,这也就意味着pthread库是被所有进程共享的,那么其他进程内部的线程都是在同一段物理内存中统一管理的


线程的局部存储

最后一个问题,线程的局部存储是什么,首先看一段代码:

__thread int g_val = 10;void* handler(void* args)
{std::cout << "I am new thread , &g_val : " << &g_val << std::endl;return nullptr;
}int main()
{pthread_t tid1;pthread_t tid2;pthread_create(&tid1,nullptr,handler,nullptr);sleep(1);pthread_create(&tid2,nullptr,handler,nullptr);return 0;
}

运行结果:

[yyf@VM-24-5-centos 24.7.28]$ ./testthread 
I am new thread , &g_val : 0x7f181b8a66fc
I am new thread , &g_val : 0x7f181b0a56fc

g_val是一个全局变量,我们之前不是说全局变量是被所有线程共享的吗?为什么打印出来的地址不是同一个呢?

实际上,由__thread修饰的全局变量不会被线程共享,而是被所有的线程私有一份,放到自己的线程局部存储中

所以线程局部存储一般存储的都是__thread修饰的共享资源,例如全局变量


http://www.mrgr.cn/news/70136.html

相关文章:

  • Nop平台的定位及发展规划
  • Flink独立集群+Flink整合yarn
  • 华为云计算HCIE-Cloud Computing V3.0试验考试北京考场经验分享
  • 制作图片木马
  • python printf中文乱码
  • 【前端】深入浅出 - TypeScript 的详细讲解
  • linux内核驱动心得
  • 整页添加水印的方法
  • idea插件开发-国际化调试
  • 985研一学习日记 - 2024.11.10
  • AI写作(七)的核心技术探秘:情感分析与观点挖掘
  • 以字符串的形式输出一个当前操作系统的路径分隔符os.altsep
  • VirtIO实现原理(1)
  • 精深之道:在专业领域迅速铸就影响力
  • C语言 | Leetcode C语言题解之第557题反转字符串中的单词III
  • 基于STM32通过TM1637驱动4位数码管详细解析(可直接移植使用)
  • js中const讲解
  • SQLite 全文检索:快速高效的文本查询方案
  • PGMP-串串040506 效益管理相关方争取治理
  • ESP32-S3模组上跑通esp32-camera(11)
  • 腾讯首个3D生成大模型Hunyuan3D-1.0分享
  • 算法求解 -- (炼码 3853 题)检查是否有路径经过相同数量的0和1
  • WIndows搭建NGINX环境
  • Python学习从0到1 day26 第三阶段 Spark ⑤ 搜索引擎日志分析
  • [C++] 函数详解
  • 嵌入式面试八股文(六)·ROM和RAM的区别、GPIO的八种工作模式、串行通讯和并行通讯的区别、同步串行和异步串行的区别