【Linux系统 学习笔记】Linux线程互斥 线程安全 可重入 不可重入 死锁

news2024/11/24 7:09:20

目录

  • Linux 线程互斥
    • 进程线程间互斥相关背景和概念
    • 互斥量
    • 互斥量的接口
    • 互斥量实现原理探究
  • 可重入与线程安全
    • 概念
    • 常见的线程不安全的情况
    • 常见的线程安全的情况
    • 常见不可重入的情况
    • 常见可重入的情况
    • 可重入与线程安全联系
    • 可重入与线程安全区别
  • 死锁
    • 死锁四个必要条件
    • 避免死锁

Linux 线程互斥

进程线程间互斥相关背景和概念

  • 临界资源:多线程执行流共享的资源就叫做临界资源
  • 临界区:每个线程内部,访问临界资源的代码,就叫做临界区

比如下面这段代码中,g_val作为一个全局变量,主线程和新创建出的线程都能访问到它,被多个执行流所共享,所以是临界资源。
新线程中g_val- -和主线程中打印了g_val ,都访问了临界资源的代码,所以就叫做临界区。

//code1
#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;

int g_val = 10000;//临界资源
void* fun(void* args)
{
    while(1)
    {
        g_val--;//临界区
        sleep(1);
    }
    pthread_exit((void*) 0);
}
int main()
{
    pthread_t t1;
    pthread_create(&t1,nullptr,fun,nullptr);
    while(1)
    {
        cout << "g_val = " << g_val << endl;//临界区
        sleep(1);
    }
    pthread_join(t1,nullptr);
    return 0;
}
  • 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用

  • 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成

在多线程的情况下,如果任由各个线程并发对临界资源进行修改的操作,就有可能导致临界资源不能达到我们预期的要求。
比如下面这段代码演示:

//code2
#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;

int g_val = 10;
void* fun(void* args)
{
    string name = static_cast<const char*>(args);
    while(1)
    {
        if(g_val > 0)
        {
            sleep(1);
            g_val--;
            cout << name << ": " << g_val << endl;
        }
        else
        {
            break;
        }
    }
    pthread_exit((void*) 0);
}
int main()
{
    pthread_t t1;
    pthread_t t2;
    pthread_create(&t1,nullptr,fun,(void*)"thread-1");
    pthread_create(&t2,nullptr,fun,(void*)"thread-2");
    pthread_join(t1,nullptr);
    pthread_join(t2,nullptr);
    return 0;
}

code2 演示了两个线程对一个全局变量g_val进行 - - 操作,每次打印g_val的值。按照我们的预期,它应该是打印到0程序就结束了。然而并不是。g_val的值出现的负数的情况。
在这里插入图片描述
出现负数的原因:

  • 在一个线程中,if语句判断条件为真后,代码中有sleep语句,此时其他线程可能会趁机进入该代码段。
  • g_val- -操作不是一个原子操作

前面我们讲到,原子性是不会被操作系统的任何调度打断的,也就说原子操作只有两种状态,要么完成了,要么未完成。

为什么g_val不是原子操作?

g_val - - 在C++语言层面上虽然只是一条语句,但是汇编语言才是计算机执行的语言,而对变量进行 - - ,实际需要以下三个步骤

  1. load:将共享变量tickets从内存加载到寄存器中。
  2. update:更新寄存器里面的值,执行-1操作。
  3. store:将新值从寄存器写回共享变量tickets的内存地址

在这里插入图片描述

**- -**操作的汇编代码如下:
在这里插入图片描述
既然- - 操作实际需要三个步骤才能完成,那么就有可能在thread-1把g_val的值读进CPU寄存器然后进行 - 1操作的时候就被CPU调度切走了,并没有把值写回到内存。假设此时thread-1读取到g_val的值是1,-1操作后,当thread-1被切走时,寄存器中的数据就叫做thread-1的上下文信息,thread-1需要将它保存起来,之后就挂起了,等待下一次调度。
在这里插入图片描述

假设此时CPU调度了thread-2,thread-2 判断此时g_val还是 1 ,所以进入if语句代码块里面,当还没开始进行 - - 操作时,如果因为时间片比较短,此时又切换到了thread-1,此时thread-1恢复上下文信息到CPU寄存器,会接着执行上一次还没完成的指令,于是就把0写回到了内存。
在这里插入图片描述
此时thread-1如果还未被切走,因为判断都g_val == 0 不满足 if 条件了,所以进入else,thread-1 结束。
重要的地方来了,此时CPU调度切回thread-2,由于thread是上次是已经满了if条件的,但还没开始- - 操作,所以才正式开始- - 操作,从内存中读数据到寄存器,是0,然后-1,最后把-1写回到内存。所以就会出现g_val为-1的情况。
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
出现负数这种情况只是线程不安全的情况之一,由于- - 操作不是原子的,在三步汇编指令中任意一步都可能被切走,也会出现其他的情况。
比如当thread-1的g_val的值为10时,执行完- - 操作汇编指令第二步把g_val变为9后,就被切走了,并没有写回到内存,thread-2被调度了,thread-2一直将g_val的值减到1,并写回到内存。此时thread-1回来了,接着执行上次的步骤,把9写回到内存,此时g_val又变为9了,thread-2做的工作白费了。如果应用到现实业务中,会出现很严重的问题。

互斥量

  • 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
  • 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之 间的交互。
  • 多个线程并发的操作共享变量,会带来一些问题。

要解决以上问题,需要做到三点:

  • 代码必须有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
  • 如果多个线程同时要求执行临界区的代码,并且此时临界区没有线程在执行,那么只能允许一个线程进入该临界区。
  • 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。

要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。

在这里插入图片描述

互斥量的接口

初始化互斥量
初始化互斥量有两种方法:

  • 静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER 
  • 动态分配
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr); 
 参数: 
  mutex:要初始化的互斥量 
  attr:初始化互斥量的属性,一般设置为NULL即可。
 返回值说明:
  互斥量初始化成功返回0,失败返回错误码

销毁互斥量
销毁互斥量需要注意:

  • 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
  • 不要销毁一个已经加锁的互斥量
  • 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);

参数说明:
 mutex:需要销毁的互斥量
返回值说明:
 互斥量销毁成功返回0,失败返回错误码。

互斥量加锁和解锁

int pthread_mutex_lock(pthread_mutex_t *mutex); //加锁
int pthread_mutex_unlock(pthread_mutex_t *mutex); //解锁

参数说明:
 mutex:需要加锁的互斥量。

返回值说明:
 互斥量加锁/解锁成功返回0,失败返回错误码。

调用 pthread_ mutex_lock 时,可能会遇到以下情况:

  • 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
  • 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量, 那么pthread_mutex_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。

使用示例:

//code3
#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;

int g_val = 10;
pthread_mutex_t mutex;//定义一把全局的锁
void* fun(void* args)
{
    string name = static_cast<const char*>(args);
    while(1)
    {
        pthread_mutex_lock(&mutex);//加锁
        if(g_val > 0)
        {
            usleep(1000);
            g_val--;
            cout << name << ": " << g_val << endl;
            pthread_mutex_unlock(&mutex);//解锁
        }
        else
        {
            pthread_mutex_unlock(&mutex);//解锁
            break;
        }
    }
    pthread_exit((void*) 0);
}
int main()
{
    pthread_mutex_init(&mutex,nullptr);
    pthread_t t1;
    pthread_t t2;
    pthread_create(&t1,nullptr,fun,(void*)"thread-1");
    pthread_create(&t2,nullptr,fun,(void*)"thread-2");
    pthread_join(t1,nullptr);
    pthread_join(t2,nullptr);
    pthread_mutex_destroy(&mutex);
    return 0;
}

执行结果:
在这里插入图片描述
注意:

  • 在大部分情况下,加锁本身都是有损于性能的事,它让多执行流由并行执行变为了串行执行,这几乎是不可避免的。
  • 我们应该在合适的位置进行加锁和解锁,这样能尽可能减少加锁带来的性能开销成本。
  • 进行临界资源的保护,是所有执行流都应该遵守的标准,这是程序员在编码时需要注意的。

互斥量实现原理探究

为什么加了锁就能体现出原子性?

引入互斥量后,当一个线程申请锁进入到临界区时,在其他线程看来,要么没有申请锁,要么锁已经释放了。只有这两种状态对其他线程才是有意义的,只关心什么时候自己才能拿到锁。
例如,图中线程1进入临界区后,在线程2,3,4看来,线程1要么没有申请锁,要么锁已经释放了,只关心自己什么时候才能拿到锁,如果检测到其他状态(如该锁已经被线程1拿到了),自己只能处于阻塞状态,等待下一次竞争锁。
在这里插入图片描述
此时对于线程2,3,4而言,它们就认为线程1的操作是原子的。

 临界区内的线程可能进行线程切换吗?如果切换了会影响到当前锁吗?

临界区的线程是可能线程切换去执行其他任务的,但是即使该线程被切走,其他线程也无法进入临界区进行资源访问,我们可以看做该线程是拿着锁被切走的,锁没用释放,也就意外着其他线程没用机会申请到锁,也就无法进入临界区进行资源访问了。

上面定义的锁也是一个全局对象,意味着它也是一个临界资源,它需要被保护吗?

锁既然是临界资源,那么它就必须被保护。可是锁的创造初心就是为了保护临界资源,那么谁来保护锁?
锁实际上是自己保护自己的,因为申请锁的过程本身就是原子的,所以锁是线程安全的。

申请锁如何保证原子性?
  • 上面我们已经说明了- -操作不是原子操作,那么++也不是原子操作,不是原子操作,可能会导致数据不一致问题。
  • 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用就是把寄存器和内存单元的数据相交换。由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期。

下面我们来看看lock和unlock的伪代码:

在这里插入图片描述
我们可以认为mutex的初始值为1,al是计算机中的一个寄存器,当线程申请锁时,需要执行以下步骤:

  • 先将al寄存器中的值清0。该动作可以被多个线程同时执行,因为每个线程都有自己的一组寄存器(上下文信息),执行该动作本质上是将自己的al寄存器清0。
  • 然后交换al寄存器和mutex中的值。xchgb是体系结构提供的交换指令,该指令可以完成寄存器和内存单元之间数据的交换。
  • 最后判断al寄存器中的值是否大于0。若大于0则申请锁成功,此时就可以进入临界区访问对应的临界资源;否则申请锁失败需要被挂起等待,直到锁被释放后再次竞争申请锁。

例如,此时内存中mutex的值为1,线程申请锁时先将al寄存器中的值清0,然后将al寄存器中的值与内存中mutex的值进行交换。
在这里插入图片描述
在这里插入图片描述
交换完成后检测该线程的al寄存器中的值为1,则该线程申请锁成功,可以进入临界区对临界资源进行访问。

而此后的线程若是再申请锁,与内存中的mutex交换得到的值就是0了,此时该线程申请锁失败,需要被挂起等待,直到锁被释放后再次竞争申请锁。
在这里插入图片描述
当线程释放锁时,需要执行以下步骤:

  • 将内存中的mutex置回1。使得下一个申请锁的线程在执行交换指令后能够得到1,形象地说就是“将锁的钥匙放回去”。
  • 唤醒等待Mutex的线程。唤醒这些因为申请锁失败而被挂起的线程,让它们继续竞争申请锁。

注意:

  • 在申请锁时本质上就是哪一个线程先执行了交换指令,那么该线程就申请锁成功,因为此时该线程的al寄存器中的值就是1了。而交换指令就只是一条汇编指令,一个线程要么执行了交换指令,要么没有执行交换指令,所以申请锁的过程是原子的。
  • 在线程释放锁时没有将当前线程al寄存器中的值清0,这不会造成影响,因为每次线程在申请锁时都会先将自己al寄存器中的值清0,再执行交换指令。
  • CPU内的寄存器不是被所有的线程共享的,每个线程都有自己的一组寄存器,但内存中的数据是各个线程共享的。申请锁实际就是,把内存中的mutex通过交换指令,原子性的交换到自己的al寄存器中。

可重入与线程安全

概念

  • 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
  • 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。

常见的线程不安全的情况

  • 不保护共享变量的函数
  • 函数状态随着被调用,状态发生变化的函数
  • 返回指向静态变量指针的函数
  • 调用线程不安全函数的函数

常见的线程安全的情况

  • 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
  • 类或者接口对于线程来说都是原子操作
  • 多个线程之间的切换不会导致该接口的执行结果存在二义性

常见不可重入的情况

  • 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
  • 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
  • 可重入函数体内使用了静态的数据结构

常见可重入的情况

  • 不使用全局变量或静态变量
  • 不使用用malloc或者new开辟出的空间
  • 不调用不可重入函数
  • 不返回静态或全局数据,所有数据都有函数的调用者提供
  • 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据

可重入与线程安全联系

  • 函数是可重入的,那就是线程安全的
  • 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
  • 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。

可重入与线程安全区别

  • 可重入函数是线程安全函数的一种
  • 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
  • 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。

死锁

  • 死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。

死锁四个必要条件

以下四个必要条件必须同时存在,才会造成死锁。

  • 互斥条件:一个资源每次只能被一个执行流使用
  • 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
  • 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
  • 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系

避免死锁

避免死锁,破坏死锁的四个必要条件之一即可。

  • 不加锁
  • 主动释放锁
  • 控制线程统一释放锁
  • 按照顺序申请锁

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【代码随想录13】前 K 个高频元素

题目 给定一个非空的整数数组&#xff0c;返回其中出现频率前 k 高的元素。 示例 1: 输入: nums [1,1,1,2,2,3], k 2输出: [1,2] 示例 2: 输入: nums [1], k 1输出: [1] 提示&#xff1a; 你可以假设给定的 k 总是合理的&#xff0c;且 1 ≤ k ≤ 数组中不相同的元素…

黑客学习笔记(自学)

一、首先&#xff0c;什么是黑客&#xff1f; 黑客泛指IT技术主攻渗透窃取攻击技术的电脑高手&#xff0c;现阶段黑客所需要掌握的远远不止这些。 二、为什么要学习黑客技术&#xff1f; 其实&#xff0c;网络信息空间安全已经成为海陆空之外的第四大战场&#xff0c;除了国…

C#(六十)之Convert类 和 Parse方法的区别

Convert数据类型转换类&#xff0c;从接触C#开始&#xff0c;就一直在用&#xff0c;这篇日志坐下深入的了解。 Convert类常用的类型转换方法 方法 说明 Convert.ToInt32() 转换为整型(int) Convert.ToChar() 转换为字符型(char) Convert.ToString() 转换为字符串型(st…

优化CSS重置过程:探索CSS层叠技术的应用与优势

目录 下面是正文~~ CSS重置方法 方法的结合 合并方法的问题 通用移除样式 顺序很重要 CSS 优先级 我们的CSS特异性冲突 CSS Layers 来拯救 Sass 预处理器支持 浏览器支持 总结 这篇文章介绍了一种名为CSS层叠的技术&#xff0c;用于优化CSS重置过程。它解释了CSS重…

网络安全(黑客技术)最全面的学习笔记

学网络安全如何成为一名黑客呢&#xff1f; 整合了全知识点及学习框架&#xff0c;本篇零基础依然适用&#xff01; 本篇涵盖内容及其全面&#xff0c;强烈推荐收藏&#xff01; 一、学习网络安全会遇到什么问题呢&#xff1f; 1、学习基础内容多时间长 学习基础语言太多&…

基于MATLAB的无人机遥感数据预处理与农林植被性状估算教程

详情点击链接&#xff1a;基于MATLAB的无人机遥感数据预处理与农林植被性状估算前言 遥感技术作为一种空间大数据手段&#xff0c;能够从多时、多维、多地等角度&#xff0c;获取大量的农情数据。数据具有面状、实时、非接触、无伤检测等显著优势&#xff0c;是智慧农业必须采…

初中级PHP程序员如何进阶学习?

如果你是一个以PHP为主的开发人员&#xff0c;只会依赖现成的框架进行增删改查&#xff0c;想提高自己又不知道从何下手&#xff0c;你可以花点时间研究一下我这个开源项目&#xff1a;酷瓜云课堂&#xff0c;这个项目以PHPJS 为主&#xff0c;负责主要的业务逻辑&#xff0c;部…

基于遗传算法的新能源电动汽车充电桩与路径选择MATLAB程序

主要内容&#xff1a; 根据城市间的距离&#xff0c;规划新能源汽车的行驶路径。要求行驶距离最短。 部分代码&#xff1a; %% 加载数据 %%遗传参数 load zby;%个城市坐标位置 NIND50; %种群大小 MAXGEN200; Pc0.9; %交叉概率 Pm0.2; %变异概率 GGAP0.…

初识Redis——Redis概述、安装、基本操作

目录 一、NoSQL介绍 1.1什么是NoSQL 1.2为什么会出现NoSQL技术 1.3NoSQL的类别 1.4传统的ACID是什么 1.5 CAP 1.5.1 经典CAP图 1.5.4 什么是BASE 二、Redis概述 2.1 什么是Redis 2.2 Redis能干什么 2.3 Redis的特点 2.4 Redis与memcached对比 2.5 Redis的安装 2.6 Docker安装 三…

基于Redisson的Redis结合布隆过滤器使用

一、场景 缓存穿透问题 一般情况下&#xff0c;先查询Redis缓存&#xff0c;如果Redis中没有&#xff0c;再查询MySQL。当某一时刻访问redis的大量key都在redis中不存在时&#xff0c;所有查询都要访问数据库&#xff0c;造成数据库压力顿时上升&#xff0c;这就是缓存穿透。…

【Python基础】- break和continue语句

在Python中&#xff0c;break和continue是用于控制循环语句的特殊关键字。 break语句用于跳出当前的循环&#xff08;for循环或while循环&#xff09;&#xff0c;并继续执行紧接着的循环外的代码。它通常用于满足某个条件时提前结束循环。例如&#xff0c;考虑以下示例&#…

《啊哈算法》第三章--枚举 与 暴力

文章目录 前言一、坑爹的奥数二、炸弹人三、火柴棍等式四、全排列总结 前言 前面我们学习了排序和栈 队列 链表&#xff0c;本节就学习暴力枚举的思想。 一、坑爹的奥数 题目1 □3 x 6528 3□ x 8256&#xff0c;在 □ 里填入相同数字使等式成立 代码如下 #include<ios…

PDF在线转PPT,不用下载软件网页在线即可转换!

PDF是我们经常在办公中使用的文件格式&#xff0c;它的兼容性和安全性使得它成为了传输文件的首选。而PPT则是我们常用的演示文稿格式&#xff0c;无论是在学校还是在公司&#xff0c;我们都需要制作演讲和汇报的PPT文件。由于这两种文件格式的重要性&#xff0c;我们经常需要进…

python的魔法函数

一、介绍 在Python中&#xff0c;魔法函数是以双下划线__开头和结尾的特殊函数。它们在类定义中用于实现特定的行为&#xff0c;例如运算符重载、属性访问、迭代等。 以下是一些常见的Python魔法函数&#xff1a; __init__: 这是一个特殊的构造函数&#xff0c;在创建类的实例…

JDBC中的Statement,PreparedStatement和CallableStatement

一旦获得连接&#xff0c;我们就可以与数据库进行交互。JDBC Statement、 CallableStatement 和 PreparedStatement 接口定义了方法和属性&#xff0c;这些方法和属性使您能够发送 SQL 命令并从数据库接收数据。 它们还定义了有助于弥合数据库中使用的 Java 和 SQL 数据类型之…

【阿Q送书第二期】《高并发架构实战:从需求分析到系统设计》

#挑战Open AI&#xff01;马斯克宣布成立xAI&#xff0c;你怎么看&#xff1f;# 文章目录 你想成为架构师嘛&#xff1f;架构经验高并发高并发架构实战特点值得推荐赠书规则 你想成为架构师嘛&#xff1f; 很多软件工程师的职业规划是成为架构师&#xff0c;但是要成为架构师很…

C语言-ubuntu下的命令

目录 linux命令 【1】打开关闭终端 【2】终端 【3】ls命令 【4】cd 切换路径 【5】新建 【6】删除 【7】复制 【8】移动 【9】常用快捷键 【10】vi编辑器 【11】简单编程步骤 任务&#xff1a; linux命令 【1】打开关闭终端 打开终端&#xff1a; 1. 直接点击 …

【1】Vite+Vue3 登录功能

一、介绍 在当今前端开发的领域里&#xff0c;快速、高效的项目构建工具以及使用最新技术栈是非常关键的。ViteVue3 组合为一体的项目实战示例专栏将带领你深入了解和掌握这一最新的前端开发工具和框架。 作为下一代前端构建工具&#xff0c;Vite 在开发中的启动速度和热重载…

sqlserver 获取根据特定符号分割字符串

CREATE function Get_StrArrayStrOfIndex (str varchar(1024), --要分割的字符串split varchar(10), --分隔符号index int --取第几个元素 ) returns varchar(1024) as begindeclare location intdeclare start intdeclare next intdeclare seed intset strltrim(rtrim(str))…

驾驶证——科目一笔记(三)

知识点1&#xff1a;红路灯 黄灯一直闪&#xff1a; 三个框的黄灯——信号暂时解除 一个框的黄灯——危险注意安全 知识点2&#xff1a;通行 看上半部分哪边有三角形 知识点2&#xff1a;禁停 知识点3&#xff1a;导向车道线 有齿可变&#xff0c;无齿导向&#xff08;按…