彻底理解多线程生产者消费者问题(含MFC、vs2017代码动画演示)

目录

一、项目简介

二、前驱知识(生产者消费者总结、进程同步问题)

1.单生产者-单消费者-一个buffer

2.单生产者-单消费者-多个buffer

3.单生产者-多消费者-多BUFFER

4.多生产者-多消费者-多个buffer

三、代码(c++ thread、MFC多线程)

1.c++thread的学习

2.MFC多线程的学习主要归纳如下(一开始用的c++thread后来改用mfc自带类库了):

四、具体模块介绍

五、项目总结


一、项目简介

0.项目下载地址(供参考):https://download.csdn.net/download/qq_39861376/11996017

电子书C++并发编程实战中文(c++thread)资源下载地址:

1.开发环境:vs2017+mfc

2.实现功能:实现对单生产者-单消费者-一个 BUFFER,单生产者-单消费者-多个 BUFFER,多生产者-多消费者-多个BUFFER

3.项目截图:

                                                                         图1.3.1 界面基本介绍

                                                      图1.3.2 动态过程(红色为有空间,绿色为有产品)


二、前驱知识(生产者消费者总结、进程同步问题)

           生产者-消费者模式是一个十分经典的多线程并发协作的模式,弄懂生产者-消费者问题能够让我们对并发编程的理解加深。所谓生产者-消费者问题,实际上主要是包含了两类线程,一种是生产者线程用于生产数据,另一种是消费者线程用于消费数据,为了解耦生产者和消费者的关系,通常会采用共享的数据区域,就像是一个仓库,生产者生产数据之后直接放置在共享数据区中,并不需要关心消费者的行为;而消费者只需要从共享数据区中去获取数据,就不再需要关心生产者的行为。

         一个比较生动的例子:什么是生产者与消费者问题?举个例子,我们去吃自助餐,在自助餐的一个公共区域放着各种食物,消费者需要就自行挑选,当食物被挑没的时候,大家就等待,等候厨师做出更多再放到公共区域内供大家挑选;当公共区域食物达到一定数量,不能再存放的时候,此时没有消费者挑选,厨师此时等待,等公共区域有地方再存放食物时,再开始生产。这就是一个生产者与消费者问题。

根据这个例子,主要分为以下几种情况:

1.单生产者-单消费者-一个buffer

        一个生产者一个消费者可以理解为 :工厂只有一个存储空间,工厂生产了一个零件,生产完工厂就满了,然后通知购买零件的人来仓库取,购买者取完之后仓库就空了,然后通知工厂可以继续生产(规定工厂生产完之后没有地方放就被浪费掉了)。

所以一个生产者一个消费一个buffer模型的规则可以概括为:

         a.初始状态:buffer内有空间,没产品

         b.规则:       当buffer为空时,生产者可以生产产品,生产后设置buffer有产品。

                              当buffer有产品时,消费者可以去buffer取产品,消费完后设置buffer有空间。

         c.PV操作: 

                                          

productor

while(true)
{
	//P操作 P操作就是-1
	判断buffer是否有空间
	if(have)
	{
	   生产
	   ....
	   生产完
	   //V操作 V操作就是+1
	   设置buffer有产品
	}
		
}

consume
while(true)
{
	//p操作
	判断buffer是否有产品
	if(have)
	{
		消费
		...
		消费完
		//V操作
		设置buffer有空间
	}
}

2.单生产者-单消费者-多个buffer

               一个生产者-一个消费者-多个buffer可以看作:工厂同样生产产品,只不过和单个buffer不一样的是,工厂存产品的空间变大了,以5个为例,当工厂生产完一个产品,工厂不会因为没有空间存产品而停止生产,而是会继续寻找下一个空间,在下一个空间继续生产产品,存储产品。与此同时,工厂会同时告诉购买者工厂现在有货了,购买者收到信息就会来工厂取产品,就在这个时间,并行发生了,生产者一边生产产品,消费者可以同时消费已经生产好的产品。

所以一个生产者一个消费多个buffer模型的规则可以概括为:

         a.初始状态:empty表示buffer内是否有空间(empty>0表示有空间),full表示是否有产品(full>0表示有产品)  

         b.规则:       当buffer为空时,生产者可以生产产品,生产后设置buffer有产品(fill+1)。

                              当buffer有产品时,消费者可以去buffer取产品,消费完后设置buffer有空间(empty+1)。

         c.PV操作:   

productor

while(true)
{
	//P操作 P操作就是-1
	判断buffer是否有空间
	if(have)
	{
	   生产
	   ....
	   生产完
            in=(in+1)%buffer_size
	   //V操作 V操作就是+1
	   设置buffer有产品
	}
		
}

consume
while(true)
{
	//p操作
	判断buffer是否有产品
	if(have)
	{
		消费
		...
		消费完
                out=(out+1)%buffer_size
		//V操作
		设置buffer有空间
	}
}

3.单生产者-多消费者-多BUFFER

                单生产者-多消费者-多BUFFER模式与上边的单消费者相比,不同的是多个消费者之间要存在竞争,多个消费者不能消费同一个产品,这里举两个例子,

1.假如生产者生产的比较慢,消费者消费的比较快

      这个问题可以理解为生产者只要一生产就会有消费者去消费,生产者和消费者之间并不存在冲突,只有一个生产者,生产者也不会有冲突,但是有问题的是这么多消费者,到底谁去消费那个产品呢?所以消费者之间就存在了竞争的关系,谁先到抢到消费的机会谁先消费,其他消费者就只能回去继续等待(相当于阻塞)。把这个机会比作锁(mutex),也就是谁先得到锁设就会得到这个机会,就可以进行消费。          

 2.假如生产者生产的比较快,消费者消费的比较慢

        生产者生产的比较快,这个时候就存在有很多个商品然后供消费者去消费,当然最开始只有一个产品的时候和上一种情况是一样的,但是随着时间的递增可能会存在两种情况,产品数量>消费者数量,消费者数量>产品数量

        对于产品数量>消费者数量,首先要保证,a.多个消费者不能消费同一个产品,同时要保证,b.多个消费者可以同时去消费,而不用去等待。(关于b,网上有很多方法不管是通过一个锁还是生产者和消费者各一个锁,都不能根本上解决这个问题,例如以下P、V操作)

        上图其实只是不同时间只有一个生产者或者一个消费者在同时进行,并没有多个生产者,多个消费者在同时进行。而且生产者和消费者去生产和消费的位置是通过in和out去决定的,第一个没生产完释放锁,就不会有第二个生产者去生产。

        对于消费者数量>产品数量,也要保证上述两点,但是不同的是,这时候并不是所有消费者都在消费,有的线程已经被阻塞了。

解决方案:

1.(自己写的lj方案,下边通过老师的指点又发现了新方案,但是先留有一个疑问,如果查找函数的查找方法比较好的话,可能比下一个方法更快,因为这也类似于人的方式去查找)通过查表的方式解决,两个信号量保持不变empty和full,但是并没有维护in和out两个下标,而是每个生产者每个消费者都去遍历全部buffer,得到可以生产的位置或者可以消费的位置,然后去设置状态位生产或消费,对于一个buffer同时只能有一个人获得🔒,对于其他的线程,如果没获得锁,就应该重新查表,直到查到一个合适的位置自己获得🔒(递归),本项目通过灰太狼和喜羊羊的动画形式体现了这一点,这样的话每次多个消费者可以同时去消费,但是这样就相当于每个buffer都有一个🔒,一个🔒控制一个buffer,在一个生产者或消费者在进行工作的时候,其他同类都不能对这个子buffer进行操作,但是查表也是一种时间上的消耗。

下边给出解决方案的简单代码,全部代码可以下载。

 

 

int query_table(int buffer[][2],int model)//查表函数,生产者查表返回可以生产的一个buffer,消费者查表返回一个可以消费的区域下标
{
	if (model)//model==1,生产者
	{
		for (int i = 0; i < BUFFER_SIZE; i++)
		{
			if (buffer[i][0] == 0 && buffer[i][1]==0) //无人操作且无产品
			{
				return i;
			}
		}
	}
	else //消费者
	{ 
		for (int i = 0; i < BUFFER_SIZE; i++)
		{
			if (buffer[i][0]==0 && buffer[i][1]==1) //无人操作且有产品
			{
				return i;
			}
		}
	}
	return -1; //都无可用空间
}
int query_until(int res,int temp, CRect btnRT, CMYWMFCDlg *pMyDialog)  //递归调用
{
	//res为上一次查表的差值,应该先过去再查表 temp为要移动的人物编号
	int new_res = query_table(mybuffer,1);
	if (new_res > res)
	{
		//向前移动过去
		int flag = new_res - res;
		if (temp == 0)//sel==0  移动美羊羊1号
		{
			for (int i = 0; i < 20; i++)
			{
				//pMyDialog->bufferb0.SetFaceColor(RGB(0, 0, 0));
				pMyDialog->m_one.GetClientRect(&btnRT);  //one 是美羊羊的 控件变量
				pMyDialog->m_one.ClientToScreen(btnRT);
				pMyDialog->ScreenToClient(btnRT);
				pMyDialog->m_one.MoveWindow(btnRT.left + 4*flag, btnRT.top, btnRT.Width(), btnRT.Height(), TRUE);
				Sleep(50);
			}
		}
		else
		{
			for (int i = 0; i < 20; i++)
			{
				pMyDialog->m_one1.GetClientRect(&btnRT);  //one 是美羊羊的 控件变量
				pMyDialog->m_one1.ClientToScreen(btnRT);
				pMyDialog->ScreenToClient(btnRT);
				pMyDialog->m_one1.MoveWindow(btnRT.left + 4* flag, btnRT.top, btnRT.Width(), btnRT.Height(), TRUE);
				//pMyDialog->m_one1.MoveWindow(btnRT.left + 100, btnRT.top, btnRT.Width(), btnRT.Height(), TRUE);
				Sleep(50);
			}
		}
	}
	else if (new_res < res && new_res!=-1) //解决-1的情况
	{
		int flag = res-new_res;
		if (temp == 0)//sel==0  移动美羊羊1号
		{
			for (int i = 0; i < 20; i++)
			{
				pMyDialog->m_one.GetClientRect(&btnRT);  //one 是美羊羊的 控件变量
				pMyDialog->m_one.ClientToScreen(btnRT);
				pMyDialog->ScreenToClient(btnRT);
				pMyDialog->m_one.MoveWindow(btnRT.left - 4 * flag, btnRT.top, btnRT.Width(), btnRT.Height(), TRUE);
				Sleep(50);
			}
		}
		else
		{
			for (int i = 0; i < 20; i++)
			{
				pMyDialog->m_one1.GetClientRect(&btnRT);  //one 是美羊羊的 控件变量
				pMyDialog->m_one1.ClientToScreen(btnRT);
				pMyDialog->ScreenToClient(btnRT);
				pMyDialog->m_one1.MoveWindow(btnRT.left - 4 * flag, btnRT.top, btnRT.Width(), btnRT.Height(), TRUE);
				Sleep(50);
			}
		}
	}
	else
	{
		return res;
	}
	//移动完之后要查表,如果一样就返回,不一样就递归
	int new_res2 = query_table(mybuffer, 1);
	if (new_res2==new_res)
	{
		return new_res2;
	}
	else
	{
		query_until(new_res, temp, btnRT, pMyDialog); //递归调用,直到查到不变的值
	}
}

DWORD WINAPI Producer4(CMYWMFCDlg *pMyDialog)  //调用
{
	pMyDialog->bufferb0.ShowWindow(true);
	pMyDialog->bufferb1.ShowWindow(true);
	pMyDialog->bufferb2.ShowWindow(true);
	pMyDialog->bufferb3.ShowWindow(true);
	pMyDialog->bufferb4.ShowWindow(true);
	CRect btnRT;
	int temp = sel;
	sel++;
	//通过查找的方式去进行,存储结构设置为一个buffer[i][2]的二维数组。
	//buffer[i][0]表示是否有人在操作(==1为在操作),buffer[i][1]表示是否有产品(==1为有产品)。
	//[i][0]为优先项,不管是否有产品或者有空间,只要[i][0]==1,就不考虑。
	//这样的话相当于每一个线程都是独立的线程,不需要依赖于前一个线程。
	while (run)
	{
		endpro = true;
		//查表
		//去指定的表,然后设定值
		//操作(赋值、取值)
		//根据不同的动作修改表中的值
		
		//查表函数 query_table
		int res = query_table(mybuffer,1);
		if (res==-1)
		{
			Sleep(1000);  //先休息再去查表
		}
		else //有可用空间
		{
			//设置占用此空间,但是如果两个线程同时查到这个表里都符合条件都去修改这个值,怎么办,怎么看到底是哪一个线程抢占了???(将时间细片化多判断几次)
			//最新的解决上边的问题的方案是 加锁
			//先走路过去
			
			ProductWalk(pMyDialog,temp,res,btnRT);
			//走路结束
			//再查一次表,可能会解决一些问题   //伪唤醒?
			int f = 0;
			WaitForSingleObject(h_Mutex,INFINITE);  //这块互斥必须要阻塞一个线程,不然就出事儿了(防止两个线程对同一个buffer操作)
			if (query_table(mybuffer,1) ==res)
			{
				//存不存在两个线程同时进入了这里,进这里说明没变的要停住,变了的要去滑动
				f = 1;
				mybuffer[res][0] = 1;//变为不可操作
				buffer[res] = res;
				mybuffer[res][1] = 1;//设置有产品
				//Sleep(1000); //为了观察出变化,直接进的话应该最后再修改buffer的颜色,如果立即修改了说明是另一个线程 把buffer修改了,这种情况是错误的
				mybuffer[res][0] = 0;//释放
			}
			ReleaseMutex(h_Mutex);
			if(query_table(mybuffer, 1) !=res && f==0) //变了呢? 重新定位或者找差值直接去差值那里去,到了差值应该再 “查表” 一次,直到不变为止。
			{
				//其他所有的没有直接找到位置的,都要在这里等锁,然后上一个位置确定好之后,其他人去找话可以继续生产的位置,如果不上锁呢?其他人可能都会去一个位置进行生产
				//这里也让继续生产,可能要用到一个递归函数 query_until
				//mutex
				WaitForSingleObject(h_Mutex1,INFINITE);
				res = query_until(res,temp,btnRT,pMyDialog);//还要有控制层,上一次查表的结果还是res没变
				mybuffer[res][0] = 1;
				ReleaseMutex(h_Mutex1);
				//mutex
				buffer[res] = res;
				mybuffer[res][1] = 1;
				mybuffer[res][0] = 0;

			}
			ConvertBtnGreen(pMyDialog,res);
			//生产完毕,可以设置可以操作标志,并让生产者回家,生产者要有标志位(res)找到回家的方向
			ProductWalkBack(pMyDialog,temp,res,btnRT);
		}
		endpro = false;
	}
	return 0;
}

2.参考上课记得笔记,还是采用in和out环形队列的方式,每次只锁in和(out+1)%buffer_size(单生产者所以生产者不用锁)就可以,(一开始总觉得会出事儿就没用),没想到那个问题如此简单得被解决了。生产者和消费者近乎同时的生产和消费,且不会发生冲突。

4.多生产者-多消费者-多个buffer

这个问题同上


三、代码(c++ thread、MFC多线程)

1.c++thread的学习

主要是 看书和看网课,用的最多的应该是unique_mutex和std::condition_variable和wait这三个函数,就模拟了P、V操作。

主要参考:

B站网课,老师虽然讲的啰嗦,但是讲的很生动明白,不会犯困:https://www.bilibili.com/video/av48611530?p=1

c++ thread的书籍(建议网课看一会之后再看,同样通俗易懂,而且书里理论和代码都讲):

2.MFC多线程的学习主要归纳如下(一开始用的c++thread后来改用mfc自带类库了):

很好的资料:https://www.cnblogs.com/zqrferrari/archive/2010/07/07/1773113.html
//1.创建信号量,初始化信号量
HANDLE CreateSemaphore(
  LPSECURITY_ATTRIBUTES lpSemaphoreAttributes,  // SD
  LONG lInitialCount,                          // initial count
  LONG lMaximumCount,                          // maximum count
  LPCTSTR lpName                           // object name
)
//h_FullSemaphore = CreateSemaphore(NULL, 0, 1, NULL);
此函数可用来创建或打开一个信号量,先看参数说明:
 lpSemaphoreAttributes:为信号量的属性,一般可以设置为NULL
 lInitialCount:信号量初始值,必须大于等于0,而且小于等于 lpMaximumCount,如果lInitialCount 的初始值为0,则该信号量默认为unsignal状态,如果lInitialCount的初始值大于0,则该信号量默认为signal状态,
 lMaximumCount: 此值为设置信号量的最大值,必须大于0
lpName:信号量的名字,长度不能超出MAX_PATH ,可设置为NULL,表示无名的信号量。当lpName不为空时,可创建有名的信号量,若当前信号量名与已存在的信号量的名字相同时,则该函数表示打开该信号量,这时参数lInitialCount 和 
lMaximumCount 将被忽略。

//2.V操作  相当于加一
ReleaseSemaphore函数用于对指定的信号量增加指定的值。   
	BOOL ReleaseSemaphore(
	HANDLE hSemaphore,
	LONG lReleaseCount,
	LPLONG lpPreviousCount
);
hSemaphore
[输入参数]所要操作的信号量对象的句柄,这个句柄是CreateSemaphore或者OpenSemaphore函数的返回值。这个句柄必须有SEMAPHORE_MODIFY_STATE 的权限。
lReleaseCount
[输入参数]这个信号量对象在当前基础上所要增加的值,这个值必须大于0,如果信号量加上这个值会导致信号量的当前值大于信号量创建时指定的最大值,那么这个信号量的当前值不变,同时这个函数返回FALSE;
lpPreviousCount
[输出参数]指向返回信号量上次值的变量的指针,如果不需要信号量上次的值,那么这个参数可以设置为NULL;返回值:如果成功返回TRUE,如果失败返回FALSE,可以调用GetLastError函数得到详细出错信息;

//3.进程的等待操作   P操作 相当于减一
WaitForSingleObject函数用来检测hHandle事件的信号状态,在某一线程中调用该函数时,线程暂时挂起,如果在挂起的dwMilliseconds毫秒内,
线程所等待的对象变为有信号状态,则该函数立即返回;如果超时时间已经到达dwMilliseconds毫秒,但hHandle所指向的对象还没有变成有信号状态,函数照样返回。
DWORD WaitForSingleObject(
	HANDLE hHandle,
	DWORD dwMilliseconds
);
hHandle[in]对象句柄。可以指定一系列的对象,如Event、Job、Memory resource notification、Mutex、Process、Semaphore、Thread、Waitable timer等。
dwMilliseconds[in]定时时间间隔,单位为milliseconds(毫秒).如果指定一个非零值,函数处于等待状态直到hHandle标记的对象被触发,或者时间到了。
如果dwMilliseconds为0,对象没有被触发信号,函数不会进入一个等待状态,它总是立即返回。如果dwMilliseconds为INFINITE,对象被触发信号后,函数才会返回。

//4.锁操作
HANDLE h_Mutex;
WaitForSingleObject(h_Mutex, INFINITE);   //加锁
ReleaseMutex(h_Mutex);                    //解锁
//5.创建线程
hThread[0] = CreateThread(NULL, 0, LPTHREAD_START_ROUTINE(Producer0), this, 0, NULL);
//mfc的CreateThread函数只能传递一个参数(必要时用结构体传参数),有点小坑,而且传递的函数必须是类外的或者类的静态成员函数,之前用c++ thread后来放弃了,可能是兼容性的问题。

四、具体模块介绍

1.对于大多数人来讲,可能觉得最难的或者最不好入手的不是多线程的问题,而是mfc控件的问题,因为我就是,所以主要讲一下mfc的控件使用和线程的联系问题:

这里用到的控件最多的是picture control、button、listBox control、radio、static

下边一个个介绍在这个小程序中怎么用的:

 1.1 picture control:

       主要功能:

       a.添加图片

       其他网上说直接把bmp位图放入资源文件里,然后在资源管理器中右击导入就可以,但是实测是不可以的:

选择导入里边并没有图片,所以我采用了 直接将图片拖到vs2017中使用截图工具,然后ctrl+c复制选中的区域,然后按照上边的方式,右击资源管理,然后新建位图,直接ctrl+v复制进去,ctrl+s保存就可以了,res会自动出现刚才保存好的图片

然后将picture contral加入到视图中,然后右击选择属性,type设为bitmap,image设置为刚才生成 的那个图片就可以了

       b.控制图片的位置和移动

        直接右击设置好的pic控件,添加变量,然后选择控件变量,取名m_one,就可以通过这个控件名字去移动pic控件,网上方法很多,使用方法介绍的很好,就不详细介绍,主要用MoveWindow函数。粘一段代码

void ProductWalk(CMYWMFCDlg *pMyDialog, int temp, int res, CRect btnRT)
{
	if (temp == 0)//sel==0  移动美羊羊1号
	{
		if (res == 0)
		{
			for (int i = 0; i < 20; i++)
			{
				pMyDialog->m_one.GetClientRect(&btnRT);  //one 是美羊羊的 控件变量
				pMyDialog->m_one.ClientToScreen(btnRT);
				pMyDialog->ScreenToClient(btnRT);
				pMyDialog->m_one.MoveWindow(btnRT.left + 15, btnRT.top, btnRT.Width(), btnRT.Height(), TRUE);
				Sleep(50);
			}
		}
}

1.2 button(mfc button control 只要mfc的这个才可以变颜色, 普通的button变不了哦)

button的显示和隐藏,button的颜色变换

同样对button设置控件变量

mfc的任何控件都可以通过 bufferb0.ShowWindow(true);来设置控件的隐藏和显示,true为显示,false为隐藏

//颜色变换 SetFaceColor ,但是如果不设置前边的属性有可能变不了颜色
void ConvertBtnRed(CMYWMFCDlg *pMyDialog, int res)
{
	switch (res)
	{
	case 0:
		pMyDialog->bufferb0.m_bTransparent = false;
		pMyDialog->bufferb0.m_bDontUseWinXPTheme = true;
		pMyDialog->bufferb0.EnableWindow(true);
		pMyDialog->bufferb0.SetFaceColor(RGB(255, 0, 0));
		break;
	case 1:
		pMyDialog->bufferb1.m_bTransparent = false;
		pMyDialog->bufferb1.m_bDontUseWinXPTheme = true;
		pMyDialog->bufferb1.EnableWindow(true);
		pMyDialog->bufferb1.SetFaceColor(RGB(255, 0, 0));
		break;
    }
}

1.3 listbox control的添加数据和清除数据

//1.添加数据
CString str; 
str.Format(_T("%d"), in);
LPCTSTR pStr1 = LPCTSTR(str);
m_product_list.AddString(pStr1 + "号"); //m_product_list为listbox的控件变量

//2.清除数据
m_product_list.ResetContent();

1.4 radio

这个方法也有很多,这里就不在总结了,最主要的是他们共享一个变量,然后根据变量的值判断选择了哪个,很简单。

1.5设置mfc皮肤

SkinMagic:

下载后解压:

将这几个文件放到项目根目录下(一般是存放cpp的目录)

然后在工程中添加 SkinMagic.h

在xxxmfc.cpp和xxxmfcDlg.cpp中添加:

然后在xxxmfc.cpp的init中添加verify这两句(corona为皮肤文件,可以更改):

在xxxmfcDlg.cpp中添加:

运行就可以看到效果了


五、项目总结

通过这个项目更好的理解了生产者和消费者这个同步模型,更好了理解了同步和互斥,多线程的概念,同时也对mfc不在那么陌生,写这篇博客的目的一是给其他人一个比较好的教程吧,因为都是自己踩得坑。第二个目的是记录一下现在学的这个知识,希望考试之后课设还能记得,忘了的话还可以在回顾一下。祝大家都有个好成绩。

 

全部评论

相关推荐

10-15 16:27
门头沟学院 C++
LeoMoon:建议问一下是不是你给他付钱😅😅
点赞 评论 收藏
分享
最近又搬回宿舍了,在工位坐不住,写一写秋招起伏不断的心态变化,也算对自己心态的一些思考表演式学习从开始为实习准备的时候就特别焦虑,楼主一开始选择的是cpp后端,但是24届这个方向已经炸了,同时自己又因为本科非92且非科班,所以感到机会更加迷茫。在某天晚上用java写出hello&nbsp;world并失眠一整晚后选择老本行干嵌入式。理想是美好的,现实情况是每天忙但又没有实质性进展,总是在配环境,调工具,顺带还要推科研。而这时候才发现自己一直在表演式学习,徘徊在设想如何展开工作的循环里,导致没有实质性进展。现在看来当时如果把精力专注在动手写而不是两只手端着看教程,基本功或许不会那么差。实习的焦虑5月,楼主...
耶比:哲学上有一个问题,玛丽的房间:玛丽知道眼睛识别色彩的原理知道各种颜色,但是她生活在黑白的房间里,直到有一天玛丽的房门打开了她亲眼看到了颜色,才知道什么是色彩。我现在最大可能的减少对非工作事情的思考,如果有一件事困扰了我, 能解决的我就直接做(去哪里或者和谁吵架等等……),解决不了的我就不想了,每一天都是最年轻的一天,珍惜今天吧
投递比亚迪等公司10个岗位 > 秋招被确诊为…… 牛客创作赏金赛
点赞 评论 收藏
分享
评论
1
1
分享
牛客网
牛客企业服务